# Unit Testing Basics — Pure Functions, Edge Cases, and the AAA Pattern

> The sturdiest foundation there is: unit tests. Learn how to test pure functions, hunt edge cases, and structure every test with the AAA (Arrange-Act-Assert) pattern — with real-world examples.

**Published:** 2026-07-06 | **Updated:** 2026-07-06

---



> This is **Part 4** of the "Frontend Testing, Done Right" series. [Browse the full series](/en/series/frontend-testing-done-right/) · [Glossary](/en/posts/frontend-testing-glossary/)

Unit tests are where all testing begins. You put one function in front of you and nail down "this input produces this output" — the smallest, fastest kind of verification there is. This one article takes you from what a unit test actually is, through pure functions, the AAA pattern, and edge cases, with real code all the way.

If you've been following the series — that first green light from last time still on? Good, we continue in the same project today. If you landed here from a search, read on just as you are. Everything you need is explained right here.

> Practice code: this installment's exact state lives in the [`step-04` tag](https://github.com/IsaacEryn/frontend-testing-lab/tree/step-04). `git checkout step-04` to follow along — or [diff it against step-03](https://github.com/IsaacEryn/frontend-testing-lab/compare/step-03...step-04) to see just what this part added.

## What we cover this time

- What a unit test is — the definition and three defining traits
- Why pure functions are a joy to test
- The AAA (Arrange-Act-Assert) pattern
- How not to miss edge cases
- Breaking things on purpose, and reading the failure report

To follow along you'll want a project with Vitest installed — the [setup article](/en/posts/frontend-testing-setup-vitest/) gets you there. On Jest? You're fine too: apart from names like `vi.fn`, today's code runs almost unchanged. And if you'd rather just read, it all works without a terminal as well.

Ready? Keep `npm run test:watch` running in a corner of your terminal. From today on we work in that rhythm — save, and see the result instantly.

{{< img src="images/contents/aaa-pattern-en.png" alt="A diagram of the three-stage Arrange-Act-Assert test structure - three horizontal bands show preparing an array of users in the Arrange step, calling filterByQuery in the Act step, and checking the result with expect in the Assert step" >}}

---

## So what is a unit test, exactly

A **unit test** takes the smallest piece of a program, isolates it from everything else, and verifies it. In frontend work, the "unit" is usually **a single function**. No network, no database, no screen involved — and three traits follow from that.

1. **They're fast** — milliseconds each. You can run hundreds on every save without noticing.
2. **Failures point precisely** — when one breaks, it narrows straight down to "this case of this function." None of the detective work you get when a whole-screen test goes red.
3. **They become the spec** — test names like "an empty query returns everything" pile up into a user manual for the function.

Unit tests aren't the whole story, of course. Screens where functions interlock belong to component tests, and full flows in a real browser belong to E2E. But the base fitness for all of those layers is built here — which is why testing strategies put unit tests at the wide bottom of the pyramid. (For the bigger picture of what to test and how much, see the [strategy article](/en/posts/frontend-testing-strategy-coverage/).)

---

## Why start with pure functions

A **pure function** keeps two promises: ①the same input always produces the same output, and ②it doesn't touch the world outside itself (no side effects).

```ts
// Pure: same input, same result, every time
function add(a: number, b: number) {
  return a + b
}

// Impure: the answer depends on the current time
function greet(name: string) {
  const hour = new Date().getHours()
  return hour < 12 ? `Good morning, ${name}` : `Hello, ${name}`
}
```

`add(2, 3)` is 5 no matter how many times you call it. `greet('Isaac')` answers differently depending on whether you ask before or after noon. From a testing standpoint, that difference is everything: with a pure function you just **feed it, catch the result, compare — done**. An impure function makes you stage the situation first ("let's pretend it's morning") — a skill we pick up next time.

So the unit-testing journey starts with pure functions, by design. Build your form against the easiest opponent, then climb.

---

## Building the search filter

Let's write the logic that will become the heart of the demo app: the **search filter**. Create a new file at `src/lib/users.ts`. (`lib` is where screen-independent, pure logic lives — keeping it separate also keeps it testable.)

```ts
// src/lib/users.ts
export type User = { id: string; name: string; email: string }

/** Pure function: case-insensitive partial search over name/email */
export function filterByQuery(users: User[], query: string): User[] {
  const k = query.trim().toLowerCase()
  if (!k) return users
  return users.filter(
    (u) => u.name.toLowerCase().includes(k) || u.email.toLowerCase().includes(k),
  )
}
```

Three lines of logic, but each earns its keep:

1. `query.trim().toLowerCase()` — strips surrounding whitespace and lowercases the query. `"  Alice "` becomes `"alice"`.
2. `if (!k) return users` — nothing left after cleanup? Then there's no query, so return the full list untouched.
3. `users.filter(...)` — keep only users whose name or email **contains** the query. Both sides are lowercased, so case is ignored.

Same input, always the same result — a pure function, as promised. Now let's nail down, in tests, that it really does those three things.

---

## The AAA pattern

A good test reads in three beats: **Arrange** — set the table, **Act** — perform the one action under test, **Assert** — check the result against expectations. In cooking terms: prep → cook → taste.

Create `src/lib/users.test.ts` (test files always sit next to their subject, with `.test.ts` appended) and write the first test in those three beats.

```ts
import { describe, it, expect } from 'vitest'
import { filterByQuery, type User } from './users'

// Arrange — ingredients shared by every test
const users: User[] = [
  { id: '1', name: 'Alice', email: 'alice@example.com' },
  { id: '2', name: 'bob', email: 'bob@test.io' },
]

describe('filterByQuery', () => {
  it('matches names case-insensitively', () => {
    // Act — the one action under test
    const result = filterByQuery(users, 'ALICE')

    // Assert — compare against the expectation
    expect(result).toEqual([users[0]])
  })

  it('matches by email too', () => {
    expect(filterByQuery(users, 'test.io')).toEqual([users[1]])
  })
})
```

A few things worth noticing:

- **`describe`** groups related tests. The report reads hierarchically — "filterByQuery › matches names case-insensitively" — so you see at a glance what broke and where.
- Putting the ingredients (`users`) at the top of the file shares the Arrange step naturally. The second test compresses all three beats into one line — but the beats are still in there.
- Searching for uppercase `'ALICE'` was no accident: one test documents **two facts at once** — "search works" and "case is ignored." This is the moment a test name becomes a specification.

The instant you save, watch mode kicks in:

```bash
 ✓ src/lib/users.test.ts (2 tests)
 ✓ src/App.test.tsx (1 test)

 Test Files  2 passed (2)
      Tests  3 passed (3)
```

---

## Aim for the edges

Bugs mostly live at the **edges**. The middle — an ordinary query — you've almost certainly eyeballed while developing. The edges? Odds are nobody has pressed there yet. Put your function in front of you and ask three questions:

1. **What if the input is "empty"?** — empty string, whitespace-only string
2. **What if the result is "empty"?** — zero matches
3. **What if the input is "weird"?** — very long, special characters, unexpected types

{{< img src="images/contents/boundary-map-en.png" alt="A horizontal spectrum diagram of the input space - the ordinary-query zone in the middle is marked as already checked by eye during development, while the edge zones on both sides (empty string, whitespace, zero results, special characters) remain unchecked and are labeled as where bugs hide" >}}

Applying questions 1 and 2 to `filterByQuery` yields two tests:

```ts
it('returns everything for an empty (all-whitespace) query', () => {
  expect(filterByQuery(users, '   ')).toHaveLength(2)
})

it('returns an empty array when nothing matches', () => {
  expect(filterByQuery(users, 'zzz')).toEqual([])
})
```

`toHaveLength(2)` is a matcher that checks "the array has length 2." Since the point is "returns everything," length is all we need — no need to compare contents. Picking the matcher that fits the intent is part of the craft.

### Now break it on purpose

The best way to confirm an edge test actually works is to **deliberately damage the code**. Delete `trim()` from `users.ts`:

```ts
const k = query.toLowerCase()   // trim() deleted!
```

Save, and watch mode goes red:

```bash
 FAIL  src/lib/users.test.ts
   × filterByQuery › returns everything for an empty (all-whitespace) query
     AssertionError: expected [] to have a length of 2 but got +0
```

How to read a failure: the first line names **which test** (`filterByQuery › returns everything…`), and the line below says **what diverged** (expected length 2, got 0). Without trim, the whitespace-only query stayed `"   "` and matched nobody. Restore `trim()` and the light turns green again — and from now on, this test instantly catches anyone (usually future you) repeating the mistake.

Running this loop once — break, watch it go red, restore — is the cheapest way to prove your tests are alarms, not decorations.

---

## A few instincts for good green lights

**Name tests as sentences.** `it('works')` tells you nothing when it fails. `it('returns everything for an empty query')` turns the failure report into a bug report.

**`toBe` and `toEqual` are different.** `toBe` asks "is it the *same thing*" (reference comparison); `toEqual` asks "are the *contents* the same" (value comparison). Numbers and strings are fine with `toBe`, but arrays and objects with identical contents are still different creatures — `toBe` fails.

```ts
expect([1, 2]).toEqual([1, 2])   // passes — same contents
expect([1, 2]).toBe([1, 2])      // fails! — different arrays
```

Everyone gets burned by this once. I lost thirty minutes to it; you just spent three and you're done.

**One `it`, one concern.** Stuff "search works AND sorting works AND paging works" into one test, and when it fails you start an investigation into which of the three died. Split them, and the report does the telling.

---

## The one-page recap

For the busy — today folded into five lines:

- A unit test = the smallest unit (usually one function) verified in isolation. Fast, precise failures, and a living spec
- Start with **pure functions** — same input, same output, so it's feed → catch → compare, no staging required
- Write every test in **three AAA beats**: Arrange → Act → Assert
- Bugs live at the edges, so aim for **edge cases** — "input empty? result empty? input weird?"
- When you're done, **break the code on purpose** and watch the red light — an alarm that never rings is a decoration

---

## The sturdiest layer is down

Four tests for a three-line function — it may look like overkill, but this function is a recurring protagonist: it resurfaces in the search UI, the API integration, and the E2E flow. This is how a protagonist builds base fitness.

We built our form against the easiest opponent — pure functions. Next come the tricksters of the real world: time that won't wait, requests that fail, errors that get thrown. On to async testing.

> **Level up**: you can now verify a pure function solidly, with the AAA pattern and edge cases.

> **Next up**: Testing async code, timers, and errors

> Bumped into an unfamiliar term? The [glossary](/en/posts/frontend-testing-glossary/) has them all, one line each.

