b
LearnBun.org
Chapter 04 · Test What You Built
Chapter 4

Test What You Built

In Chapter 3 you wrote countWords. You ran the script on sample.txt and saw 9 words. That's encouraging, but it's one data point. Does it handle an empty string? A line with extra spaces? A file with tabs and newlines? You don't know yet. You believe the function works because you wrote it, not because you've checked.

This chapter fixes that. We'll write tests for countWords and run them with Bun's built-in test runner. By the end you'll have a small suite of tests that runs in under a second and tells you, automatically, whether your function still works.

This is the third of Bun's four pillars: the test runner.

Move countWords Into Its Own File

Right now countWords lives inside index.ts, alongside all the script code that reads arguments, opens files, and prints to the cow. That was fine when the function only had one caller. Now we have two: the script, and the tests we're about to write.

A test file imports the function it tests. But importing index.ts would run everything in index.ts — including the argument check that exits the program when there's no filename. The tests would never get a chance to run.

The fix is to put countWords in its own file. Code you want to share — between a script and its tests, or between two scripts — belongs in a separate file. The script imports it. The tests import it. Each gets exactly what it needs and nothing else.

Create a new file called wordcount.ts:

// wordcount.ts
export function countWords(text: string): number {
  return text
    .trim()
    .split(/\s+/)
    .filter((word) => word.length > 0).length;
}

The function is identical to the one in index.ts. The only new piece is the export keyword, which makes the function available to other files that import it.

Now update index.ts. Remove the local countWords function and import it from the new file:

// index.ts
import { say } from "cowsay";
import { countWords } from "./wordcount.ts";

const filename = Bun.argv[2];

if (!filename) {
  console.error("Usage: bun run index.ts <filename>");
  process.exit(1);
}

const file = Bun.file(filename);

if (!(await file.exists())) {
  console.error(`File not found: ${filename}`);
  process.exit(1);
}

const contents = await file.text();
const count = countWords(contents);
console.log(say({ text: `${count} words` }));

Run the script to make sure nothing broke:

$ bun run count sample.txt

You should see the same cow as before, saying 9 words. The script behavior is unchanged. We just moved the function.

Write Your First Test

Create a new file called wordcount.test.ts next to wordcount.ts:

// wordcount.test.ts
import { test, expect } from "bun:test";
import { countWords } from "./wordcount.ts";

test("counts two words", () => {
  expect(countWords("hello world")).toBe(2);
});

Three pieces here. test defines a single test case. expect is how you make assertions about values. Both come from bun:test, Bun's built-in test module — no package to install. The third import pulls in the function we want to test.

The test itself reads almost like English: expect countWords("hello world") to be 2.

Run It

In the terminal, run:

$ bun test

You should see something like:

bun test v1.3.13

wordcount.test.ts:
✓ counts two words [0.45ms]

 1 pass
 0 fail
 1 expect() calls
Ran 1 tests across 1 files. [12.00ms]

Bun found the file because it ends in .test.ts. That's the convention — any file matching *.test.ts, *.test.js, *_test.ts, or *.spec.ts gets picked up automatically. No configuration needed.

See a Failing Test

Failing tests aren't scary. They're how the test runner tells you something's wrong. Let's see what one looks like.

Change the expected value to 3:

// wordcount.test.ts
import { test, expect } from "bun:test";
import { countWords } from "./wordcount.ts";

test("counts two words", () => {
  expect(countWords("hello world")).toBe(3);
});

Run the tests:

$ bun test

You should see:

wordcount.test.ts:
✗ counts two words [0.52ms]
  expect(received).toBe(expected)

  Expected: 3
  Received: 2

 0 pass
 1 fail

The output tells you exactly what went wrong: it expected 3, but got 2. That's the whole point of a failing test — it points at the mismatch between what you thought the code did and what it actually does.

Change the expected value back to 2 and run bun test again. You should be back to green.

Add More Cases

One test isn't much of a safety net. Let's cover the cases we were uncertain about at the start of the chapter:

// wordcount.test.ts
import { test, expect } from "bun:test";
import { countWords } from "./wordcount.ts";

test("counts two words", () => {
  expect(countWords("hello world")).toBe(2);
});

test("returns zero for an empty string", () => {
  expect(countWords("")).toBe(0);
});

test("counts a single word", () => {
  expect(countWords("hello")).toBe(1);
});

test("handles multiple spaces between words", () => {
  expect(countWords("hello    world")).toBe(2);
});

test("ignores leading and trailing whitespace", () => {
  expect(countWords("  hello world  ")).toBe(2);
});

test("handles tabs and newlines", () => {
  expect(countWords("hello\tworld\nfoo")).toBe(3);
});

Run the tests:

$ bun test

You should see all six pass:

 6 pass
 0 fail
 6 expect() calls

Each test pins down a behavior. countWords already handled all of these — we wrote it that way in Chapter 3 — but now we have proof. If someone (you, next month) changes the function and breaks one of these cases, the test runner will tell us immediately.

Group Tests with describe

When tests share a subject, it's helpful to group them. Wrap the tests in a describe block:

// wordcount.test.ts
import { describe, test, expect } from "bun:test";
import { countWords } from "./wordcount.ts";

describe("countWords", () => {
  test("counts two words", () => {
    expect(countWords("hello world")).toBe(2);
  });

  test("returns zero for an empty string", () => {
    expect(countWords("")).toBe(0);
  });

  test("counts a single word", () => {
    expect(countWords("hello")).toBe(1);
  });

  test("handles multiple spaces between words", () => {
    expect(countWords("hello    world")).toBe(2);
  });

  test("ignores leading and trailing whitespace", () => {
    expect(countWords("  hello world  ")).toBe(2);
  });

  test("handles tabs and newlines", () => {
    expect(countWords("hello\tworld\nfoo")).toBe(3);
  });
});

Run the tests again:

$ bun test

The output now groups the tests under countWords:

wordcount.test.ts:
countWords > counts two words [0.45ms]
countWords > returns zero for an empty string [0.12ms]
countWords > counts a single word [0.10ms]
...

describe is purely organizational. It doesn't change what the tests do — it just labels them. When a file has tests for several different functions, describe keeps the output readable.

Use Watch Mode

Just like bun run --watch from Chapter 2, the test runner has a watch mode. Run:

$ bun test --watch

Now edit wordcount.test.ts — change a number, save, and watch the tests re-run automatically. Bun reruns the suite whenever a test file or any file it imports changes.

This is how you'll work day to day: leave bun test --watch running in one terminal, edit code in another, and get instant feedback. Stop it with Ctrl + C.

Where Tests Live

A few conventions worth knowing:

File naming. Bun discovers tests by filename. Files ending in .test.ts, .test.js, _test.ts, or .spec.ts are tests. Anything else is ignored.

Co-location. We put wordcount.test.ts next to wordcount.ts. That's one common pattern — tests live alongside the code they test. The other common pattern is a separate tests/ folder. Both work. Bun doesn't care.

For a project this size, co-location is simpler. You can see the test file and the file it tests right next to each other in the file listing.

Save Your Work with Git

Add the changes:

$ git add .
$ git commit -m "Extract countWords and add tests"

Three files are involved in this commit: the new wordcount.ts with the extracted function, the updated index.ts that imports from it, and the new wordcount.test.ts.

Conclusion

You wrote your first tests. You used test and expect to assert behavior, watched a test fail and explain itself, grouped related tests with describe, and ran the whole suite in watch mode. Along the way, you learned a habit that will pay off in every project from here on: code you want to share lives in its own file, separate from the script that uses it.

This chapter introduced the third of Bun's four pillars: the test runner. bun test runs files matching the test naming conventions, with a Jest-compatible API and no setup. You've now used three of the four pillars — the runtime in Chapter 2, the package manager in Chapter 3, and the test runner here. The bundler is still ahead.

Notify me

Get notified when Part II ships.

One email per release. No spam, no upsells. Just the next set of chapters when they're ready.