I got tired of writing Protobuf test fixtures by hand.
You know the pattern. A Person message with an Address, a oneof for contact
details, a few repeated fields. You need one for a test, so you write a builder.
Then the schema gains a field and your builder is silently incomplete. Then
another test needs a slightly different Person and you copy the builder. Six
months later there are four of them and nobody trusts any.
So I wrote bufaker, which generates a
fully populated message from the schema alone:
import { mock, mockList } from "@pret-a-porter/bufaker";
import { PersonSchema } from "./gen/person_pb.js";
const person = mock(PersonSchema); // fully typed as `Person`
const people = mockList(PersonSchema, 5, { seed: 42 });
No per-message code. Add a field to the .proto and it gets populated on the
next run.
This post isn't really about the library though. It's about the four things I
got wrong on the way, because they're the interesting part.
The idea: descriptors at runtime
The thing that makes this possible is that
protobuf-es v2 emits a runtime
descriptor alongside your TypeScript types. That PersonSchema export isn't
just a type — it's a live object describing every field.
So the whole generator is a recursive walk driven by one discriminant:
switch (field.fieldKind) {
case "scalar": return scalarValue(field.scalar, ctx);
case "enum": return pickEnumValue(field.enum, ctx);
case "message": return generateMessage(field.message, deeper(ctx));
case "list": /* n elements of the element kind */
case "map": /* n entries, keys and values generated separately */
}
That's the core. It's maybe 200 lines, and it works for any message you've ever
generated, including ones the library has never seen.
This is also why the library only supports protobuf-es. ts-proto and friends
emit plain TypeScript interfaces — there's no descriptor to walk, so there's
nothing for this approach to reflect over. That's not a TODO, it's a different
design.
Writing values through protobuf-es's reflect() API rather than building object
literals turned out to matter too: it handles the awkward cases (setting a
oneof member clears its siblings, map keys get converted to their proper types,
jstype=JS_STRING fields get their bigint-to-string conversion) so I didn't have
to.
So far, so tidy. Now the parts that bit me.
1. A descriptor describes structure, not meaning
google.protobuf.Timestamp is structurally just this:
message Timestamp {
int64 seconds = 1;
int32 nanos = 2;
}
Nothing there says nanos must be under one billion, or that seconds should
land in a range a human would recognise. So the generic walk, doing exactly what
it's told, produced this:
seconds=914153170354185471n nanos=1291688086
A timestamp in roughly the year 28 billion, with a nanos field 30% over its legal
maximum. Serializing it throws:
cannot encode message google.protobuf.Timestamp to JSON:
must be from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59Z inclusive
Duration came out just as broken — negative seconds with positive nanos, which
protobuf forbids, because the two fields have to agree in sign.
So well-known types need special-casing by fully-qualified name, before the
generic recursion gets to them. Fine. But this gave me a useful rule for
which types need it: a type only needs a special case if it carries a
constraint its descriptor can't express.
That rule immediately told me I'd over-engineered. I'd written nine generators
for the google.protobuf.*Value wrapper types — StringValue, Int32Value and
friends. But a wrapper is genuinely just { value: <scalar> } with nothing extra
to satisfy. I deleted all nine, captured output for 80 seeded mocks before and
after, and the results were byte-identical. Twenty-one lines of code that did
nothing.
2. Recursive schemas have no bottom
message TreeNode {
string label = 1;
TreeNode child = 2;
}
Obvious in hindsight, easy to forget while you're deep in a field-kind switch.
There's no structural base case here — a faithful walk recurses until the stack
dies.
The fix is a depth counter, but the part I like is what happens at the limit: the
field is simply left unset. That's always valid, because Protobuf has no
non-nullable message fields. So there's no error to raise and no partial object
to explain. You just stop.
Mutually recursive pairs (Ping holds a Pong holds a Ping) fall out of the
same counter for free.
3. "Reproducible" is a stronger claim than it sounds
Seeding is the whole point for snapshot tests:
expect(toJson(PersonSchema, mock(PersonSchema, { seed: 42 }))).toMatchSnapshot();
I seeded Faker, asserted that two calls with the same seed matched, watched it
pass, and moved on.
Then a snapshot failed in CI that passed locally. Or rather — it passed when I
ran that test file alone and failed in the full suite, which is the kind of
symptom that makes you doubt your test runner.
The actual cause: faker.date.recent() is relative to now. Any message
containing a Timestamp produced different output tomorrow than today. The seed
made the random parts reproducible and left a wall clock wired straight into
the output.
So a seed now also pins the date reference to a fixed epoch. Unseeded mocks still
get present-day timestamps, because that's what you want when eyeballing output.
The general lesson: seeding your RNG doesn't make a generator deterministic if
anything else non-deterministic is still in scope. Time is the one you'll miss.
4. A TypeScript union that quietly ate itself
The overrides API takes either a generator function or a literal value, so I
typed it the obvious way:
type Override = OverrideFn | unknown; // ← useless
OverrideFn | unknown collapses to unknown. Every union with unknown in it
does. Which meant TypeScript had no signature to contextually type the parameter
against:
mock(PersonSchema, {
overrides: {
email: (ctx) => ctx.faker.internet.email(),
// ^^^ implicitly has an 'any' type
},
});
The main ergonomic feature of the API was untyped, and it compiled fine. It only
surfaced when I added type-level tests with expect-type, which is an argument
for having them.
The fix is to spell out the non-function half instead of reaching for unknown:
type StaticOverride =
| string | number | bigint | boolean | symbol | null | undefined | object;
type Override = OverrideFn | StaticOverride;
Now ctx infers, and literals still assign.
What it looks like in practice
const person = mock(PersonSchema, {
seed: 42,
maxDepth: 4,
overrides: {
city: () => faker.location.city(), // any field named `city`
"Person.address.city": "Berlin", // more specific key wins
"Address.country": "Germany", // by declaring type
nicknames: ["ada", "lovelace"], // whole repeated field
},
});
Field names also drive about sixty built-in heuristics, so email gets an email
address, id gets a UUID, createdAt gets a recent date. On by default —
heuristics: false gives you purely type-driven values instead.
Known limits
Being honest about scope is more useful than pretending:
- protobuf-es only, for the reason above.
- proto3 is what the test suite covers. Nothing assumes proto3 — field presence is read from the descriptor — but proto2 and Editions are untested.
-
google.protobuf.AnyandFieldMaskare left unset.Anyneeds a type registry to choose and pack a payload; a randomFieldMasknames fields of a request that doesn't exist. Both are skipped rather than raising, so a message that merely contains one still mocks fine.
A bonus lesson, free of charge
npm view bufaker returned a 404, so I confidently reported the name as
available. Two failed release runs later:
403 Forbidden - PUT https://registry.npmjs.org/bufaker
Package name too similar to existing packages faker, buffer
npm runs a similarity check that normalizes punctuation and matches fuzzily, and
it runs server-side at publish time. A 404 from npm view tells you nobody
owns a name. It tells you nothing about whether you can publish it. My second
choice, protofaker, collided with an existing proto-faker.
Scoped names skip the check entirely, which is why it ships as
@pret-a-porter/bufaker.
- Repo: https://github.com/pret-a-porter/bufaker
- npm: https://www.npmjs.com/package/@pret-a-porter/bufaker
It's 0.1.0, so the API may still move. If you're generating with protobuf-es and
still hand-writing fixtures, I'd genuinely like to know whether the override
matching rules make sense to someone who didn't write them.
Top comments (0)