The Complete Beginner's Guide to Unit Testing
Why + How
The engineers who don't write tests usually say they have "no time" ─ the real reason is they "never built the habit." Once you do, the time you spend writing tests gets cancelled out by the "debugging time you save," so your total time actually drops. This piece breaks it all down: why to write them, how to write your first one, how to choose a framework, and how much coverage to aim for.
First, let's bust 4 myths
01 "Writing tests takes too much time"
Short term, sure ─ you spend an extra 30% of your time writing tests.
Long term, it's the opposite ─ with untested code, changing one thing three months later costs you an hour of debugging; with tests, you're done in five minutes.
Net result: engineers who write tests spend less time overall.
02 "Testing is something you add later"
"Add it later" = never add it.
Here's why: you can't just bolt tests on once the code is written. Code written without tests is usually hard to test (too much coupling, too many side effects) ─ to add tests you first have to refactor, and the friction is 5x what it would have been at the time.
So it's either write it now or never write it. There's no in-between.
03 "Higher coverage is always better"
Wrong.
Code with 100% coverage can still have bugs; code with 80% coverage can be rock solid.
What matters isn't the "number" ─ it's "whether you've covered the critical paths and edge cases."
04 "I tested it manually, no need to write tests"
Manual testing solves the "right now" problem.
Automated testing solves the "changing the code three months from now" problem.
You test it by hand today, forget what you changed tomorrow, the bug ships the day after, and the day after that you spend 3 hours hunting for the cause ─ those 3 hours = the 30 minutes you "saved" by not writing the test.
Not worth it.
What is a "unit test"
The "unit" in unit test means:
- 1 function
- 1 class
- 1 component (React/Vue)
- 1 responsibility of 1 module
Characteristics:
- Runs fast (each test < 100ms)
- Touches nothing external (no DB, no API, no file reads)
- Predictable results (same input always gives the same output)
- Independent of each other (test A breaking doesn't break test B)
The testing pyramid ─ 3 layers
| Layer | Proportion | What it tests | How fast |
|---|---|---|---|
| Unit Test | 70% | A single function / module | Milliseconds |
| Integration Test | 20% | Multiple modules working together (incl. DB) | Hundreds of ms to seconds |
| E2E Test | 10% | An entire user flow (incl. browser) | Seconds to minutes |
Most beginners get the proportions wrong ─ they write a pile of E2E tests and no unit tests. The result: the suite takes 30 minutes to run, and when something breaks you have no idea where.
The ideal is a "pyramid": lots of unit tests, few E2E tests.
How to write your first test
Choosing a framework
Taking JavaScript/TypeScript as the example:
- Vitest ─ the top pick for new projects: fast, integrates well with Vite
- Jest ─ the most familiar in the React ecosystem, with the most learning resources
- Node Test Runner ─ built into Node 18+, zero dependencies (great for library authors)
For new projects I recommend Vitest. The syntax is 90% the same as Jest, and it's 5x faster.
Installing it
npm i -D vitest
# in package.json
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
Your first test
Say you have a function:
// src/lib/calculate.ts
export function calculateTax(price: number, rate: number): number {
if (price < 0) throw new Error('Price cannot be negative')
return price * (1 + rate)
}
The test:
// src/lib/calculate.test.ts
import { describe, it, expect } from 'vitest'
import { calculateTax } from './calculate'
describe('calculateTax', () => {
it('returns the tax-inclusive price', () => {
expect(calculateTax(100, 0.05)).toBe(105)
})
it('leaves the price unchanged when rate is 0', () => {
expect(calculateTax(100, 0)).toBe(100)
})
it('throws when price is negative', () => {
expect(() => calculateTax(-1, 0.05)).toThrow('Price cannot be negative')
})
})
Run it:
npm test
# result
✓ src/lib/calculate.test.ts (3 tests)
✓ calculateTax
✓ returns the tax-inclusive price
✓ leaves the price unchanged when rate is 0
✓ throws when price is negative
Congrats, you've got your first test.
The 5 traits of a good test
01 One test checks one thing
Bad example:
it('calculateTax works', () => {
expect(calculateTax(100, 0.05)).toBe(105)
expect(calculateTax(100, 0)).toBe(100)
expect(() => calculateTax(-1, 0.05)).toThrow()
})
The problem ─ if the first assertion fails, the rest don't run, and you can't see the full picture.
Correct ─ one it per case.
02 The test name should describe behavior, not the function name
- ❌
it('calculateTax') - ❌
it('test 1') - ✅
it('returns the tax-inclusive price') - ✅
it('throws when price is negative')
The test name is for whoever reads the test report. They should be able to read off "what it's supposed to do."
03 Arrange / Act / Assert ─ a 3-part structure
it('drops the price by 10% after a 10% discount', () => {
// Arrange: set up the data
const order = { items: [{ price: 100, qty: 2 }] }
const discount = 0.1
// Act: run the behavior under test
const total = applyDiscount(order, discount)
// Assert: verify the result
expect(total).toBe(180)
})
This structure lets anyone look at your test and get what it does within 5 seconds.
04 Test the boundaries, not just the "happy case"
Most beginners only write the happy path. Senior engineers also write:
- Boundary values: 0, 1, -1, max, min
- Empty inputs: empty array, empty string, null, undefined
- Bad inputs: wrong type, value out of range
- Concurrency: simultaneous calls
05 Tests don't depend on order
Bad example:
let user
it('creates a user', () => {
user = createUser('A') // test #1
expect(user.id).toBe(1)
})
it('user still exists', () => {
expect(user.id).toBe(1) // depends on test #1
})
Wrong ─ the two tests share state, and running just the second one breaks.
Correct ─ every test must be able to run on its own. Use beforeEach to reset state:
describe('User', () => {
let user
beforeEach(() => {
user = createUser('A') // reset before every test
})
it('user id is 1', () => {
expect(user.id).toBe(1)
})
it('user name is A', () => {
expect(user.name).toBe('A')
})
})
What to test and what not to test
Do test
- Business logic (calculations, rules, decisions)
- Boundary conditions (max, min, empty, null)
- Error handling (which errors get thrown, and when)
- Critical user flows (login, payment, signup)
Don't test (or low priority)
- Third-party libraries (already tested by their authors)
- Plain getters/setters (no logic)
- Pure UI rendering (a visual test / snapshot is enough)
- Config files
The principle ─ test the parts that are "easy to break and costly when they do." Don't test the parts that "won't break, and don't matter if they do."
How much coverage to aim for
This is the most-argued question. Here's a pragmatic answer:
- Pure hobby project ─ don't chase a number, having even 1 test is fine
- Real product, early stage ─ 50-60% (covering the core logic)
- Real product, mature ─ 70-80%
- Critical modules (payment, auth) ─ 90%+
- Beginner just starting out ─ start at 30% and build up
The key ─ coverage is a "diagnostic tool," not a "goal".
Teams fixated on "100% coverage" usually write a pile of junk tests just to pad the number.
4 common pitfalls
01 Testing the implementation, not the behavior
Wrong:
it('calculateTax uses multiply internally', () => {
const spy = vi.spyOn(math, 'multiply')
calculateTax(100, 0.05)
expect(spy).toHaveBeenCalled()
})
The problem ─ you refactor multiply into add + loop and the test breaks. But the actual behavior didn't change.
Correct ─ test "given this input, what comes back," not "which functions it used internally."
02 Too much mocking
A test that mocks every dependency ─ it passes, then blows up in real integration.
The principle:
- Mock external resources (DB, API, third parties)
- Don't mock your own modules (unless you genuinely need isolation)
03 Writing tests just to pass, not to catch bugs
// a test written just to pass
it('returns something', () => {
const result = calculate()
expect(result).toBeDefined()
})
This test will always pass, but it can't catch a single bug.
The test for a good test ─ "if I broke this piece of code, would this test fail?" It's only useful if the answer is "yes."
04 Not maintaining bad tests
A common one: some test in CI is constantly flaky (fails at random), and everyone learns to "just re-run it."
This is slow suicide. A flaky test teaches the team that "test results don't matter," and then when there's a real bug, nobody cares that it's red.
Spot a flaky test ─ fix it or delete it immediately, don't leave it sitting there.
Writing tests in the AI era
AI tools (Cursor / Claude Code) are incredibly efficient at writing unit tests. A recommended workflow:
- You write the function first
- Ask the AI to list "which edge cases this function needs tested"
- You review the list and add the ones the AI missed
- Ask the AI to write the test code
- You review the test code and confirm it's actually testing something
The key ─ you still have to review. AI-written tests can look correct but miss the critical case.
One last reminder
Writing tests isn't doing one more thing ─ it's doing the same thing a different way.
It's a gift from tomorrow's you to today's you.
Once you build the habit of "write the function, then write the test," you'll find that:
- You're no longer afraid to refactor
- You're no longer anxious when shipping
- You add new features 2x faster than colleagues without tests
- When a project comes up in an interview, you can explain "why it's designed this way"
The habit takes about 3 months to form. Push through it, and it pays off for life.
Already writing tests but it keeps getting messier?
A 30-minute 1-on-1 consultation for NT$1,500 ─ I'll look at one of your recent PRs and help you sort out "what to test, what to skip, and how to organize it."