I use fuzz testing because most test suites are too polite. They prove that a few expected inputs work. They do not ask what happens when the inputs arrive out of order, repeat a value, contain unexpected text, or land on the boundary of the rules I wrote.
I recently added four native Go fuzz targets to another product I am building. They cover backlog ordering, cycle planning, customer-priority payload normalization, and simulation-trace minimization. I did not add them to chase a coverage number. I added them because each area has a property that should remain true across a range of inputs.
The backlog fuzzer checks that a complete reorder produces contiguous ranks and that applying the accepted order again does not change it. The planning fuzzer checks that the same inputs lead to the same plan. The payload fuzzer checks that accepted data is valid UTF-8, has no empty or duplicate labels, and survives a round trip unchanged. The trace-minimizer fuzzer checks that a minimized trace still fails for the same reason.
Those are useful tests because they describe promises the product makes. They do not depend on one ticket, one customer, or one memorable bug.
Fuzz the logic you own
I use fuzzing for code I can inspect and change: parsers, normalizers, sorting, limits, serialization, state transitions, and reducers. These are places where a small assumption can become a production fault.
The approach I trust is simple. Start with a few seeds that represent normal, empty, duplicate, reversed, and boundary cases. Bound the generated input before it reaches expensive code. State the invariant in plain language. Then let the fuzzer keep changing the input until it finds a contradiction.
For founders, this means you do not need to fuzz the whole company. Pick the small pieces of logic that turn messy input into a product decision. Ask what must never happen. An accepted payload must not contain invalid text. A plan must not change when the input does not change. A reorder must not invent or drop records. A failure minimizer must not remove the condition that made the trace fail.
If the fuzzer finds a counterexample, keep the smallest reproducible input as a regression. That turns a surprising failure into a permanent test case.
Zig 0.16.0 makes this style more direct with std.testing.Smith. Smith replaces the old raw byte slice interface with typed value generation. It can generate values, favor weighted cases, fill buffers, and constrain values to a range. I like that shift because a good fuzz test should describe the input space that matters to the code, not force every test to decode arbitrary bytes first.
Keep simulation testing separate
Fuzzing does not replace simulation testing or deterministic simulation testing. It solves a different problem.
An application can have correct local logic and still fail because a provider returns partial data, a tool times out, a response arrives out of order, or a dependency becomes unavailable. Those are system behaviors. I test them in a deterministic environment where I can replay the sequence and inspect the recovery path.
In a stealth product I am developing, I keep the boundary explicit. Fuzz tests look for faults in internal helpers and invariants. Simulation tests exercise workflow contracts, sequencing, replay, degraded provider and tool behavior, and recovery. When a simulation finds a stable failure, I turn it into a named regression instead of leaving it as a story from one bad run.
Fuzz the logic you own. Simulate the systems around it. Keep the failures you find. That is how I make reliability a habit instead of a promise.