A characterization test records what a piece of code actually does today, right or wrong, so you can tell whether an upgrade changed it. Write them before upgrading code that has thin tests and a dependency you are about to change. They answer "did the behavior move?" rather than "is the behavior correct?".
Definition and purpose
Most upgrade guidance assumes you have good tests. Many services do not, especially older ones with a vulnerable library you now need to bump. A characterization test, sometimes called a golden master or snapshot test, fills that gap. You call the code with a range of inputs, capture the outputs as they are, and assert they stay the same after the change.
The key idea is that you are not judging the output. If the old code returns a strange date format, the test records the strange format. After the upgrade, a difference is a signal to look, and then you decide whether the change is a fix or a break.
When it is worth the effort
| Situation | Write characterization tests? |
|---|---|
| Library with strong tests around it already | Not needed |
| Parser, formatter or serializer you are upgrading | Yes, the output is easy to capture |
| Legacy module with no tests and a vulnerable dependency | Yes, before touching anything |
| Code with heavy side effects and no clear output | Maybe, capture the calls it makes instead |
| Code you plan to delete soon | No |
Focus on code that calls the package being upgraded. You do not need to characterize the whole application.
How to write them
- Find the seam. Identify the function or endpoint closest to the dependency that returns something observable.
- Collect inputs. Pull realistic examples from logs, fixtures or support tickets. Remove personal data and secrets first. Add edge cases: empty values, very large values, odd encodings, boundary dates.
- Run the old code and save the output. Store inputs and outputs as files in the repository, so a diff is reviewable.
- Assert equality. The test replays the inputs and compares with the saved outputs.
- Bump the dependency and run the tests.
- Triage each difference. Is it a bug fix you want, a harmless change such as ordering or whitespace, or a real break?
For differences you accept, update the saved output in the same pull request and say why. The commit history then explains every behavior change that came with the upgrade.
Handling noise
Naive snapshots fail on timestamps, random identifiers and map ordering. Normalize those before comparing. Freeze the clock and seed randomness in the test setup. A characterization suite that fails for unrelated reasons gets deleted, so keep it deterministic.
What it does not tell you
It shows that behavior is unchanged for the inputs you chose. It says nothing about inputs you did not choose, performance, or security properties. Pair it with a smoke test and a staged rollout. See what a smoke test should check and security regression testing.
Security angle
If you are upgrading because of an advisory, include inputs that exercised the vulnerable behavior. The test should show a difference in exactly that case, from unsafe to safe. If it shows no difference, either the fix did not take effect or your inputs did not reach it, and both are worth knowing.
Safeguard's reachability analysis can show whether your code calls the vulnerable function, which helps you choose inputs that matter. Free scan: gold.safeguard.sh/scan.
Quick check
Is a characterization test the same as a snapshot test?
They are close. A snapshot test saves output and compares later. A characterization test uses the same mechanism with a specific goal: documenting existing behavior before a change.
Should I keep these tests after the upgrade?
Keep the useful ones as regression tests and delete the rest. If the module later gains proper unit tests, retire the snapshots.
Last reviewed: 19 October 2026.