Skip to content
LogluneStart free

Unit Tests: How to Write and Run Them in 5 Steps

By Loglune

A unit test isolates one function and supplies fixed arguments, data, and clock before asserting one expected result.
What a unit test fixes before it runs
Unit
Which function or method is under test?
Inputs
Which arguments, data, and clock does the test pass in?
Assertion
Which single result does the test expect?

A unit test is a test that checks one small piece of code, typically a function or a method, in isolation from the rest of the program. Unit testing is a form of software testing in which isolated source code is tested to validate expected behavior. To write a unit test, pass fixed inputs to the unit, call the unit once, and assert the result; then let CI run every unit test on every change.

This guide is for developers who write the code they test. It closes a gap that many unit testing guides leave open: they show the assertion, but not the hidden inputs such as the clock or a configuration value that make the same test pass today and fail tomorrow. After reading, you can write a unit test with Arrange, Act, Assert, move hidden inputs into arguments, and run the suite in CI in 5 steps. The steps fit any language with a test framework; the code samples use TypeScript and Vitest. Integration and end-to-end tests are outside the scope.

Reproduce a customer’s flag state

Start free

What is a unit test?

A unit test is a small piece of code that calls one unit of the application with known inputs and checks the output. Put another way, a unit test is a block of code that verifies the accuracy of a smaller, isolated block of application code, typically a function or method.

The size of a unit is a team decision. Martin Fowler’s essay Unit Test (checked October 5, 2026) says that teams treat a function, a class, or a group of closely related classes as a unit, and that “However you define it doesn’t really matter.” Fowler names three common properties instead: small scope, written by the programmer, and fast. Software developers are typically in charge of conducting unit testing, although quality assurance (QA) engineers may also take part.

A good unit test has these characteristics:

Characteristic Description
Fast Unit tests should take little time to run: milliseconds.
Isolated Unit tests have no dependencies on any outside factors, such as a file system or database.
Repeatable The test always returns the same result if you don’t change anything in between runs.
Self-checking The test detects whether it passed or failed without any human interaction.
Timely A unit test shouldn’t take a disproportionately long time to write compared to the code being tested.

Unit tests have a limit. A unit test proves that one unit behaves as the test expects, and says nothing about whether the units work together; integration tests and end-to-end tests cover that. Unit-level tests contrast with testing at the integration or system level.

What is a unit test example?

A unit test example has three parts: a unit with no hidden inputs, a test that passes known arguments, and an assertion on the return value. The unit below returns the shipping fee for an order total. The unit takes the free-shipping threshold as an argument instead of reading a settings file, so the unit test controls every input.

// The unit: pure, with every input passed in.
export function shippingFee(total: number, freeFrom: number): number {
  return total >= freeFrom ? 0 : 5;
}
import { describe, expect, it } from 'vitest';
import { shippingFee } from './shipping';

describe('shippingFee', () => {
  it('charges nothing when the total reaches the threshold', () => {
    expect(shippingFee(50, 50)).toBe(0);
  });
  it('charges 5 when the total is below the threshold', () => {
    expect(shippingFee(49.99, 50)).toBe(5);
  });
});

Each test sets one total on purpose and checks one branch, and the first test sits exactly on the threshold, the edge case where an off-by-one comparison fails. Every Boolean decision statement requires at least two tests, one for true and one for false, which is why the example has two tests for one comparison.

What are the different types of unit tests?

Unit tests are usually grouped two ways: by who runs the tests, and by how much of the surrounding code the tests replace.

Type What it means Source
Manual unit testing A developer runs the unit and checks the result by hand
Automated unit testing A test framework runs the unit tests and reports each failure
Solitary unit tests Collaborators of the unit are replaced with test doubles Fowler, Unit Test
Sociable unit tests The unit calls its real collaborators Fowler, same essay

Automated unit testing is faster and less expensive, and it requires minimal developer intervention. Fowler credits Jay Fields with the terms solitary and sociable and writes that the original xunit testers made no attempt to go solitary unless a collaborator was awkward to call, such as a remote credit card verification service.

A test double is the tool that makes a unit test solitary. A test double is software that satisfies a dependency so that the test need not depend on production code. Mocks, stubs, and fakes are kinds of test doubles. A value passed in as an argument, as in the shipping fee example above, needs no test double at all.

Before you write unit tests: Arrange, Act, Assert

Write each unit test in three steps, in this order. “Arrange, Act, Assert”, the 3 A’s of unit testing, is a common approach for writing unit tests, and it helps clearly separate what is being tested from the Arrange and Assert tasks:

  1. Arrange: create the unit and every input the unit reads, including data, time, and configuration.
  2. Act: call the unit once.
  3. Assert: check the result against one expected value.

Before it runs, a unit test fixes three things: which function or method is under test, which arguments, data, and clock the test passes in, and which single result the test expects. The Arrange step is where most unrepeatable unit tests break. A unit that reads the system clock, a database, or a remote configuration service has an input the test did not arrange, so the same test can pass today and fail tomorrow with no code change. The clock is an example: a unit that calls DateTime.Now cannot be fully controlled by the test until the time is passed in.

How to write unit tests and run them in CI

1. Identify the unit

Choose one function, method, or class with a clear input and output. The unit might be a function, class, or method.

2. Move hidden inputs into arguments

Pass the clock, the data, and the configuration values into the unit as arguments or through a constructor. A unit that receives its inputs can be tested with no file system, no database, and no network, which is the isolation a good unit test needs.

3. Write one test per behavior

Write one test for each branch and each edge case, named after the behavior the test checks. Test both outcomes of every condition, and put one test on each boundary value, such as the threshold in the shipping fee example.

4. Run the unit tests on every change in CI

Run the whole unit test suite on every push and every pull request, before slower integration tests. A suite that runs on every change also works as a regression test suite for the behaviors it covers. Write tests as code and run the test code automatically every time you make changes in the software code. The CI/CD pipeline guide covers where the unit test stage sits in a pipeline, and trunk-based development explains why a fast unit test stage matters when everyone merges to one mainline every day.

5. Fix the code or the expectation, then run the tests again

Treat each failed unit test as either a defect in the code or an expectation that the change made obsolete. The last step, debugging and resolving issues, ends with running the unit tests again to confirm the fix.

Pin the flag version your unit tests run against with Loglune

A feature flag value is one more hidden input. A unit test that reads a live flag value can pass today and fail tomorrow, because anyone with access to the flag console can change the value without a commit. Loglune keeps that input fixed: publishing flag rules in Loglune creates an immutable version with a number that only increases, and a request that sends X-Loglune-Version: N to POST /api/flags evaluates version N. Every plan can evaluate a pinned version in development, CI, or staging. Evaluation is a pure function of the version and the evaluation context, so the same version and context return the same value on every run, even after someone publishes a newer version. Testing every combination of flag values is covered in testing flags.

The run below took place on October 5, 2026, on a local copy of the Loglune evaluation server with synthetic data, not a customer case. Version 1 of the flag new-checkout serves on to accounts whose plan is pro or enterprise, and version 2 narrows the rule to enterprise only. The unit checkoutPage(newCheckout: boolean) returns 'new-checkout-page' when the value is true, and the test runs the same assertion for the account acct-pro-1 (plan pro) on both versions:

async function flagAt(version: number): Promise<boolean> {
  const res = await fetch(`${baseUrl}/api/flags`, {
    method: 'POST',
    headers: {
      authorization: `Bearer ${deliveryKey}`,
      'x-loglune-version': String(version),
      'content-type': 'application/json'
    },
    body: JSON.stringify({ context: { key: 'acct-pro-1', attributes: { plan: 'pro' } }, flag: 'new-checkout' })
  });
  return (await res.json()).result.value;
}

describe.each([1, 2])('checkoutPage with new-checkout pinned to version %i', (version) => {
  it('shows the new checkout page to acct-pro-1 (plan pro)', async () => {
    expect(checkoutPage(await flagAt(version))).toBe('new-checkout-page');
  });
});

Version 1 returned "variationId":"on","value":true with reason targetMatch, and version 2 returned "variationId":"off","value":false with reason default. The test printed:

AssertionError: expected 'legacy-checkout-page' to be 'new-checkout-page' // Object.is equality
 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)

The unit and the test code were identical on both versions, so the failure names the rule change in version 2. Pass evaluatedAt in the context when a rule depends on time, because a pinned version treats time conditions as not met without it instead of reading the test machine’s clock. Evaluating a pinned version does not count as a reproduction; rebuilding the flag state at a past time counts as one, and the Free plan includes 5 reproductions (pricing).

Pin the flag version your unit tests run against

Publish your flag rules in Loglune as immutable versions, then send the X-Loglune-Version header from your unit tests in CI to evaluate the same version on every run. Every plan, including Free, can evaluate a pinned version in development, CI, or staging, and the Free plan includes 5 reproductions.