DEV Community

Lucas Azevedo
Lucas Azevedo

Posted on

Why generating random CPFs in your tests is wrong (and how I fixed it with a zero-dependency lib)

Every time I needed a CPF to seed a test database or fill out a form in a dev environment, I ran into the same problem: generic generators gave me a number that looked like a CPF 11 digits, nicely formatted that broke the moment it hit any real validation.

For non-Brazilian readers: the CPF is Brazil's individual taxpayer ID, like a national ID number. The CNPJ is its business equivalent. Both are used everywhere in Brazilian software.

This is a silent problem. Your test passes with data that production would reject. And you find out about the bug far too late.

I got tired of it and decided to build something better: massa-br a deterministic generator of valid Brazilian data, with zero dependencies.

In this article I want to focus less on "install and use" and more on what I learned building it: why "valid by construction" changes everything, and how the CPF check digit actually works.

The problem: "looks like a CPF" ≠ "is a CPF"

A CPF isn't just 11 random digits. The last two digits are calculated from the first nine. They exist precisely to catch typos.

Which means: if you roll 11 random digits, the odds of the last two matching the correct calculation are roughly 1 in 100. In practice, almost every "random" CPF is invalid.

That's why a generic generator betrays you: it produces something that passes a \d{11} regex, but fails a check-digit validation which is exactly what any serious system does.

The shift: generate valid by construction

The core idea of massa-br is simple: instead of rolling all 11 digits and hoping, you roll only the 9 base digits and calculate the 2 check digits. That way the result is valid always, by construction.

To make this work, I had to actually understand how the check digit is computed. It's worth sharing, because almost nobody explains it clearly.

How the CPF check digit works

The first check digit comes out like this:

  1. Take the 9 base digits.
  2. Multiply each one by a decreasing weight: the first by 10, the second by 9, and so on down to the ninth by 2.
  3. Sum everything and take the remainder of the division by 11 (sum % 11).
  4. If the remainder is less than 2, the digit is 0. Otherwise, the digit is 11 - remainder.

The second check digit follows the same logic, but now over the 10 digits (the 9 base ones plus the first check digit you just calculated), with the weights starting at 11.

In code, computing a single digit looks like this:

function calculateDigit(digits) {
  const startingWeight = digits.length + 1;
  const sum = digits.reduce(
    (acc, digit, i) => acc + digit * (startingWeight - i),
    0
  );
  const remainder = sum % 11;
  return remainder < 2 ? 0 : 11 - remainder;
}
Enter fullscreen mode Exit fullscreen mode

With that, generating a valid CPF becomes: roll 9 digits, calculate the first check digit, calculate the second. Never invalid.

The CNPJ (the business ID) follows the same principle different weights, but the same idea of a modulo-11 check digit. Once you understand the pattern, it repeats.

The second pillar: determinism

Generating valid data solves half the problem. The other half is reproducibility.

If every run of your test uses a different, random CPF, you lose two things: the ability to reproduce a failure, and the stability of snapshots. A test that generates random data is a test that can fail differently tomorrow without you having changed anything.

That's why massa-br is seed-based. You pass a seed, and the same seed always generates the same data:

import { createGenerator } from 'massa-br';

const gen = createGenerator('my-seed');
const person = gen.pessoa();

console.log(person);
// The same seed always produces the same person reproducible.
Enter fullscreen mode Exit fullscreen mode

Ran the test, it failed, want to investigate? Same seed, same data, same scenario. The bug doesn't get to escape.

The third pillar: zero dependencies

I made a point of not pulling in a single dependency. In a small, focused library, every dependency is a surface of risk a broken update, a transitive vulnerability, an abandoned package.

The result is a tiny tarball and a dependency tree that is literally just your project. You install massa-br and you install nothing else alongside it.

How to use it

Install:

npm install massa-br
Enter fullscreen mode Exit fullscreen mode

Basic usage:

import { createGenerator } from 'massa-br';

const gen = createGenerator('test-seed');

const person = gen.pessoa();
// valid, coherent Brazilian data, ready to use in your tests
Enter fullscreen mode Exit fullscreen mode

The library covers the main Brazilian data types CPF, CNPJ, CEP (postal code), and phone numbers all valid by construction.

What I learned building this

Three things stuck with me after this project:

Understanding the domain matters more than the language. The code to generate a CPF is trivial. What made the library useful was understanding why the check digit exists and how it's calculated that's domain knowledge, not syntax.

"Passes the test" and "is correct" are different things. Data that passes a regex but fails real validation is the kind of trap that only shows up in production. Generating valid by construction kills the entire class of problem.

Publishing is a milestone in itself. Putting your first library on npm, with a decent README and tests, teaches you about versioning, packaging, and documentation things we rarely practice in closed projects.


massa-br is on npm and GitHub. If it's useful to you, a star helps and if you'd like to contribute new generators (card numbers + Luhn and bank slips are on the roadmap), issues and PRs are welcome.

How do you handle test data today? Let me know in the comments.

Top comments (0)