In Chapter 3, you wrote countWords and saw it print 9 words for sample.txt.
That proves one thing: it worked once.
It does not prove the function handles an empty file, extra spaces, tabs, newlines, or whatever you accidentally break next week. That is why this chapter pulls countWords into its own file and puts tests around it.
By the end, you will have a small test suite that runs in milliseconds and tells you whether the word counter still behaves the way you expect.
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 needs to import the function it tests. If we import index.ts, we do not just get countWords; we also run the CLI: argument parsing, file reads, cowsay output, and possibly process.exit(1). That is too much baggage for one function test.
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);
});
There are only two testing tools here: test names the behavior, and expect checks the result. Both come from bun:test, so there is no Jest, Vitest, or extra setup step hiding offstage.
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
A failing test is not a disaster. It is the test runner telling you exactly where your assumption stopped matching the code.
Open wordcount.ts and temporarily break the function:
// wordcount.ts
export function countWords(text: string): number {
return text.split(" ").length;
}
That version looks tempting. It splits on spaces and counts the pieces. It even works for "hello world".
Now change the test to use extra spaces:
test("handles multiple spaces between words", () => {
expect(countWords("hello world")).toBe(2);
});
Run the tests:
$ bun test
You should see a failure like this:
Expected: 2
Received: 5
That is the kind of bug tests are good at catching. The broken version counted the empty gaps between spaces as words.
Put countWords back:
export function countWords(text: string): number {
return text
.trim()
.split(/\s+/)
.filter((word) => word.length > 0).length;
}
Run bun test again. You should be back to green.
Add More Cases
These are not random examples. Each one protects a small decision in the implementation: trim(), the \s+ regex, and the empty-string case.
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 protects one behavior. The function already handled these cases, but now that behavior is written down in code. If you simplify the function later and accidentally break tabs, newlines, or extra spaces, the test suite will complain before a user does.
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 does not change the tests. It gives the output a label. That matters more once a file has tests for several functions, but it is worth learning while the file is still small.
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 the loop I want you to get used to: leave bun test --watch running, make a small change, and let the terminal tell you whether you broke anything. 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.
For this book, we will keep tests close to the code until the project gets large enough to make that annoying.
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 now have tests around the first piece of logic in the book.
The important change was not bun test by itself. The important change was separating the pure part of the program from the CLI wrapper. countWords lives in wordcount.ts, where it can be imported, tested, and reused without running the whole script.
You also saw the testing loop:
- write a small test
- run
bun test - watch it fail when the code is wrong
- fix the code
- keep
bun test --watchrunning while you work
In the next chapter, we return to the web app and start turning the tiny server into something with real pages, a layout, static files, and route tests.