Safeguard
Best Practices

Writing your first Jest unit tests for security-critical JavaScript

Jest ships to ~41M weekly npm installs with assertions, mocking, and coverage built in — here's how to structure your first tests around security logic.

Safeguard Research Team
Research
7 min read

Jest is currently at version 30.4.2, pulls roughly 41 million weekly downloads on npm, and lists more than 13,380 dependent projects — making it the default test runner most JavaScript engineers meet first, whether they chose it or inherited it on a legacy codebase. What fewer engineers learn early is that a test suite's value for security work depends less on how many tests you write and more on how the code under test is shaped. A function that validates an email address, sanitizes a filename, or checks a permission boundary is only easy to test if it is written as a small, pure function — one that takes an input and returns an output, with no hidden reach into a database, the DOM, or the network. This tutorial walks through setting up Jest from zero, writing the first handful of tests against that kind of function, and — the part most Jest tutorials skip — deliberately testing the boundary and malformed-input cases that matter for security-relevant code, not just the inputs a happy-path demo would use. By the end you'll have a working test file, a sense of why OWASP treats unit tests as a legitimate layer of input-validation verification, and a pattern you can reuse on your own validators.

Why does test structure matter more than test count for security logic?

A test suite full of assertions is worthless if the logic it exercises can't be isolated in the first place. The widely accepted testability principle is that pure functions — deterministic, side-effect-free, given the same input they always return the same output — are trivially unit-testable, while logic tangled into an Express route handler, a database call, or DOM manipulation requires mocks, stubs, or a full integration test just to reach the line you care about. For security-relevant code specifically — input validators, sanitizers, encoders, permission checks — this matters twice over: not only is entangled logic harder to test, it's harder to reason about, which is exactly the property you need when the function's job is rejecting attacker-controlled input. The fix is structural, not procedural: before writing a single Jest test, extract the validation or sanitization decision into its own function that a handler calls, rather than writing the check inline inside the handler. That one refactor is what turns "we should really test our input validation" from an aspiration into a five-line test file.

What does a first Jest test actually look like?

Start with npm install --save-dev jest and a package.json script "test": "jest". Jest auto-discovers files matching *.test.js or anything under a __tests__ directory, so no separate test-runner configuration is required to get started. A minimal validator and its test:

// validators.js
function isValidUsername(input) {
  if (typeof input !== "string") return false;
  return /^[a-zA-Z0-9_]{3,20}$/.test(input);
}
module.exports = { isValidUsername };
// validators.test.js
const { isValidUsername } = require("./validators");

test("accepts a normal alphanumeric username", () => {
  expect(isValidUsername("alice_92")).toBe(true);
});

test("rejects usernames with spaces", () => {
  expect(isValidUsername("alice 92")).toBe(false);
});

Running npx jest executes both cases and prints a pass/fail summary. expect() is Jest's built-in assertion API — no separate library, no configuration file needed to get a first green test.

Why should validation and sanitization logic live in pure functions?

OWASP's Testing Guide, in its coverage of integrating security tests into the development workflow, explicitly calls for verifying input and output validation at the function and method level using unit test frameworks — it names JUnit, NUnit, and CUnit as examples in other languages, and Jest fills that same role for JavaScript. The guide's premise only works if the validation logic is actually isolated in a callable function rather than inline inside a route handler or middleware chain; you cannot unit-test a check that only exists as an if statement buried inside an Express callback without spinning up an HTTP request. OWASP's separate Input Validation Cheat Sheet adds a second principle worth encoding directly into your tests: allowlisting — defining exactly what is authorized and rejecting everything else — is a more robust approach than denylisting known-bad patterns, and validation should happen as early as possible when data enters the system. Writing a pure isValidUsername or sanitizeFilename function and testing it in isolation is the practical mechanism for satisfying both guidelines at once: it enforces allowlisting logic and puts it at the entry boundary where Jest can reach it directly.

How do you test boundary conditions and malicious inputs, not just happy paths?

Happy-path tests confirm a function works; boundary and adversarial-input tests confirm it fails safely, which is the property that actually matters for security logic. For the username validator above, that means testing the length boundary explicitly — a 20-character string should pass and a 21-character string should fail — along with inputs an attacker is more likely to send than a well-behaved user: empty strings, null, undefined, strings containing ../ or <script>, and non-string types like objects or arrays, since JavaScript's loose typing means a validator that assumes it always receives a string can throw or silently coerce in unexpected ways. Jest's test.each() API lets you table-drive these cases instead of writing one test() block per input:

test.each([
  ["", false], [null, false], ["a".repeat(21), false],
  ["../../etc/passwd", false], ["ok_user1", true],
])("isValidUsername(%p) -> %p", (input, expected) => {
  expect(isValidUsername(input)).toBe(expected);
});

This is also where Jest's built-in mocking earns its keep: jest.fn() lets you assert that a rejected input never reaches a downstream call like a database query, confirming the validator is actually load-bearing rather than decorative.

What's the difference between Jest's built-in tooling and assembling Mocha with Chai and Sinon?

Jest bundles an assertion library (expect), a mocking system (jest.fn, jest.mock), snapshot testing, and code coverage reporting via Istanbul (jest --coverage) in a single package with zero additional installs. Mocha, by contrast, is a test runner only — it ships no assertion syntax and no mocking primitives, so a comparable Mocha setup typically means adding Chai for expect/assert style assertions and Sinon for spies, stubs, and mocks, plus a separate coverage tool like nyc. Neither approach is wrong, and Mocha's modularity is a deliberate design choice that some teams prefer for flexibility. But for a team writing its first security-relevant test suite, Jest's all-in-one default lowers the activation energy: jest --coverage immediately shows which branches of a validator — including the rejection branches, which are the ones that matter most for security logic — were never exercised by a test, without configuring a second tool to get that number.

How Safeguard Helps

Unit tests like the ones above verify that your validation and sanitization functions behave the way you intended when called directly — they don't tell you whether untrusted input can reach a dangerous sink through a path nobody wrote a test for. That's a complementary problem, not the same one: Safeguard's application security testing traces untrusted input from source to sink across JavaScript and TypeScript source code, flagging data-flow paths where attacker-controlled input reaches a sensitive operation without passing through validation at all. A Jest suite proves isValidUsername rejects ../../etc/passwd when you call it; Safeguard's static analysis is what catches the second code path six months later where a new handler forgot to call it in the first place. Run both, and you get proof of intended behavior alongside proof that every path an attacker could actually take goes through it.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.