Skip to content
rsdlt.dev
Go back

Testing Brewing Math: Unit Tests for IBU, Color and Carbonation Calculators

A brewing calculator that’s wrong by a few percent looks perfectly fine on screen. Nobody notices until a batch comes out too bitter, too dark or over-carbonated. That’s what makes domain math the most important code to test in Foambyte, and the easiest to under-test.

Over the last two weeks I built Foambyte’s first set of calculators and converters, together with about 330 unit tests. This post covers how they’re structured and the testing patterns that work well for numeric domain code.

What’s covered

Converters: gravity, mass, temperature, volume and beer color (SRM, EBC and degrees Lovibond).

Calculators:

Each one is a pure function: numbers in, number out, no React and no I/O. That’s what makes them easy to test and reusable anywhere: the calculator screens, the recipe editor, and the server if we ever need them there.

Validate inputs at the boundary

Every function validates its inputs with a Zod schema and throws on invalid data rather than returning a plausible-looking wrong number:

export function calcHopUtilization(originalGravity: number, boilTimeMinutes: number, correctionFactor = 1) {
  // ...input schema safeParse; throws the ZodError on invalid input...
  const bignessFactor = 1.65 * 0.000125 ** (originalGravity - 1);
  const boilTimeFactor = (1 - Math.exp(-0.04 * boilTimeMinutes)) / 4.15;
  return bignessFactor * boilTimeFactor * 100 * correctionFactor;
}

A negative boil time or a gravity of 0.5 is a bug upstream, and failing loudly makes it visible in tests and in the UI.

Five kinds of tests

Each module’s tests are grouped the same way, which makes gaps easy to spot in review.

1. Reference values. Known inputs with known outputs, compared with a tolerance:

it("matches known Tinseth value at 1.050 SG, 60 min, C=1.0", () => {
  expect(calcHopUtilization(1.050, 60, 1.0)).toBeCloseTo(23.0664, 3);
});

2. Round trips. Converting there and back must return the original value: Fahrenheit to Celsius to Fahrenheit, SRM to EBC to SRM. Round trips catch inverted constants that a single reference value can miss.

3. Behavior, not just values. Some properties must hold for any input. Hop utilization goes up with boil time and down with wort gravity. A correction factor scales the result linearly:

it("applies correction factor linearly (e.g., pellet 1.10)", () => {
  expect(pellet).toBeCloseTo(base * 1.10, 6);
});

These monotonicity checks are cheap and catch sign errors and swapped arguments that reference values sometimes don’t.

4. Edge cases. Zero boil time, very high gravity, beer that’s already more carbonated than the target. The priming sugar calculator clamps to zero instead of suggesting negative sugar.

5. Validation errors. Invalid inputs must throw, and the tests assert that they do.

Tip

Pick tolerances deliberately. toBeCloseTo(x, 3) is a real check; toBeCloseTo(x, 0) only checks to the nearest unit, which is fine for “about 7 g/L of sugar” but too loose for a hop utilization percentage. Write down why each tolerance is what it is.

Formulas over lookup tables

My first keg-pressure calculator used a CO2 carbonation table and interpolated between rows. It worked, but interpolation produces small jumps at row boundaries and only covers the table’s range. I replaced it with a polynomial regression fitted to the same relationship, which is smooth, short, and easy to test across the whole supported range (roughly 30–65 °F). The input schema enforces that range, in whichever unit the user enters.

Units are part of the math

Every calculator works internally in one unit system and converts at the boundary. The converters behind that are the most heavily tested code in the set (gravity, mass, temperature and volume alone account for well over 200 tests), because a unit bug silently corrupts every calculator downstream.

Takeaways


I’m Rodrigo Santiago, a product leader who builds. I co-founded Foambyte with my brother Diego, a brewmaster who leads the product as CPO, and I designed and built the platform: architecture, frontend, backend, mobile and infrastructure. If you need someone who can own both the product and the code, let’s talk on LinkedIn.


Share this post:

Previous Post
Offline-First Editing with TanStack Query Persistence and Last-Write-Wins Patches
Next Post
ArkType vs Zod: Why I Standardized on Zod Across Forms, API and Database