How to write unit tests as a junior developer
Junior developers either over-test (every getter and setter, 95% coverage of code that doesn't matter) or under-test (zero tests, prayer-driven development). Both are bad. Senior engineers write a small number of high-leverage tests around the parts of the code that would actually break in production. Here's how they think about it.
What a unit test actually is
A unit test is a small program that calls one specific function (or method, or class) with known inputs and asserts the output is correct. It runs fast (milliseconds), doesn't hit a database or network, and either passes (green) or fails (red).
Example in Python (pytest):
def add(a, b):
return a + b
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-2, -3) == -5
def test_add_zero():
assert add(0, 5) == 5
Three tests, each a few lines. Each one calls add with specific inputs and asserts a specific output. If add ever breaks, at least one test goes red.
That's the whole concept. Tests are programs that test programs. Everything else, frameworks, mocks, fixtures, is plumbing.
The AAA pattern: every test has three parts
Good unit tests follow a structure called Arrange-Act-Assert:
- Arrange, set up the inputs and any state the test needs.
- Act, call the function or method being tested.
- Assert, check that the output (or state change) matches what you expected.
Visualized:
def test_apply_discount_caps_at_50_percent():
# Arrange
cart = Cart(items=[
Item(name="Widget", price=100),
Item(name="Gadget", price=200),
])
# Act
total = apply_discount(cart, percent=80) # request 80% off
# Assert
assert total == 150 # 50% off cap, not 80%
Three blocks, separated by blank lines. A reader skimming this test understands in 5 seconds: "set up a cart, apply a discount that exceeds the cap, verify the cap kicks in." That's a clean test.
Tests that don't follow AAA are usually unreadable: setup interleaved with assertions, multiple acts in one test, asserting things you didn't actually test. Stick to AAA.
What to test: the 80/20
The most useful tests aren't 1:1 with every function. They're focused on three categories:
1. Logic with branches
Anything with an if, switch, or ?: is a place where the code can go wrong. Each branch is a thing to test, at minimum the happy path through it, ideally the edge case that breaks it.
2. Calculation and transformation
Functions that take input and produce a derived output: pricing, formatting, parsing, mapping, validation. These are the easiest to test (no external dependencies) and the most valuable (logic bugs hide here).
3. Edge cases that have bitten the codebase before
The "we shipped this bug last quarter" cases. When you fix a real bug, write the test that would have caught it. This is how teams stop making the same mistake twice.
What's NOT worth unit-testing
- Trivial getters and setters, they don't have logic that can break.
- Code that mostly delegates to a library you don't own. (Test the library's code? They have their own tests.)
- Pure framework wiring (route registration, dependency injection setup). Better caught by integration tests.
- UI layout, visual regression tests catch this better than unit tests.
Coverage percentage is a vanity metric. 100% coverage of trivial code is worse than 60% coverage of the gnarly logic that actually breaks production. Cover what matters; skip what doesn't.
Naming tests so they document the code
Good test names read like sentences and describe a concrete scenario. Bad ones don't.
Bad:
def test_user():
def test1():
def test_login():
Good:
def test_user_login_returns_401_when_password_is_incorrect():
def test_user_login_locks_account_after_5_failed_attempts():
def test_user_login_succeeds_with_valid_credentials():
The name should answer two questions: what's being tested? what's the expected outcome? Months later, when a CI test fails, the name alone tells you what broke without reading the test body.
JavaScript convention is similar but with describe/it blocks:
describe('user login', () => {
it('returns 401 when password is incorrect', () => { ... });
it('locks account after 5 failed attempts', () => { ... });
it('succeeds with valid credentials', () => { ... });
});
Same idea, different syntax. The describe block becomes the prefix; the it string completes the sentence.
One assertion per test (mostly)
Tests with five assertions are hard to debug, when one fails, you don't immediately know which assertion failed without reading the output. Prefer one assertion per test, or assertions that are tightly related (e.g., shape + content).
Worse:
def test_create_user():
user = create_user("alice@example.com", "password123")
assert user.id is not None
assert user.email == "alice@example.com"
assert user.is_active is True
assert user.created_at is not None
assert user.role == "user"
Better, split into focused tests:
def test_create_user_assigns_an_id():
user = create_user("alice@example.com", "password123")
assert user.id is not None
def test_create_user_starts_active():
user = create_user("alice@example.com", "password123")
assert user.is_active is True
def test_create_user_default_role_is_user():
user = create_user("alice@example.com", "password123")
assert user.role == "user"
Yes, it's more code. Yes, when one fails, you know exactly which behavior broke. The "more code" is a feature: each test is a tiny piece of documentation describing one specific contract.
The exception: when assertions are tightly coupled (e.g., "the response is a JSON object with these three fields"), grouping them makes sense.
Practice writing tests against real broken code
InternQuest's test track gives you broken implementations and asks you to add the unit tests that catch the bug. 25+ test missions across Python and JavaScript. Free.
Try a test mission →Mocking: when and how
Real unit tests don't hit databases, don't make HTTP requests, don't read files. So when your code does, you replace those dependencies with mocks (fake versions you control).
Example: testing a function that sends a welcome email after signup. The function calls a real email service in production. In a test, you replace that service with a stub:
from unittest.mock import Mock
def test_signup_sends_welcome_email():
fake_email_service = Mock()
user = signup(
email="alice@example.com",
password="password123",
email_service=fake_email_service,
)
fake_email_service.send.assert_called_once_with(
to="alice@example.com",
subject="Welcome to App",
)
The test doesn't actually send email, it asserts that signup would have, with the right arguments. Fast, deterministic, no real email goes out, no real network connection needed.
The mocking trap juniors fall into
Over-mocking. The test ends up mocking so many things that it tests "the function calls the things it's supposed to call", which proves nothing about whether the code works. The mocks become a duplicate of the implementation.
Rule of thumb: mock the boundaries of your code (databases, external APIs, file system, time), but not the internals. If you're tempted to mock a function in the same module you're testing, you're probably testing the wrong thing.
Test fixtures and setup
Sometimes tests share setup, they all need a logged-in user, or a populated database. Don't copy-paste; use fixtures.
In pytest:
import pytest
@pytest.fixture
def alice():
return User(id=1, email="alice@example.com", role="admin")
def test_alice_can_delete_posts(alice):
assert can_delete_posts(alice) is True
def test_alice_can_approve_users(alice):
assert can_approve_users(alice) is True
The alice fixture is created fresh for each test (no shared state to corrupt other tests). In Jest, the equivalent is beforeEach:
describe('alice (admin)', () => {
let alice;
beforeEach(() => {
alice = new User({ id: 1, email: 'alice@example.com', role: 'admin' });
});
it('can delete posts', () => {
expect(canDeletePosts(alice)).toBe(true);
});
});
Common test smells
Testing implementation details
"This function should call foo and then bar in that order." If you refactor the function to call baz internally instead but produce the same output, the test fails. The test was wrong, it tested how, not what. Test the public behavior, not the private steps.
Flaky tests
Tests that sometimes pass and sometimes fail. Causes: shared mutable state, timing dependencies, randomness, network calls, real time/dates. Either fix the flakiness or delete the test, flaky tests train the team to ignore CI failures, which is worse than no test.
Slow tests
Unit tests should run in milliseconds. If your unit test takes 2 seconds, it's secretly an integration test (probably hitting a DB or doing real I/O). Either speed it up with mocks, or move it into the integration test suite.
Tests that copy-paste the implementation
If your test re-implements the function it's testing inside the assertion ("if input is 5, expected is 5*2 + 3 = 13"), you've duplicated the bug. Use specific known-good values instead: "if input is 5, expected is 13", without re-deriving 13 in the test.
Test-driven development: should juniors do it?
Test-driven development (TDD) means writing the test before writing the code. The cycle: red (write a failing test), green (write the simplest code that passes), refactor (clean it up).
It's polarizing in the industry. Honest take for juniors:
- TDD is genuinely useful for pure logic, billing calculations, parsers, validators. The tests force you to think about edge cases up front.
- TDD is awkward for exploratory work where you don't know what the API should look like yet.
- TDD is a waste for trivial code or UI work.
You don't need to commit to TDD or reject it religiously. Use it where it helps; skip it where it doesn't. Even occasional TDD on the gnarly bits is useful practice for thinking about edge cases.
Running tests locally and in CI
Two muscle-memory commands:
# Run the whole suite
pytest # Python
npm test # JavaScript (most projects)
go test ./... # Go
# Run a single test file (faster, focused)
pytest tests/test_users.py
npm test users.test.js
# Run a single test by name
pytest -k test_alice_can_delete_posts
npm test -t "alice can delete posts"
You'll use the focused versions while developing, running the full suite every time is too slow. CI runs the full suite on every push (see our CI/CD guide).
The mindset shift
Tests aren't homework you do for the team's benefit. They're a tool that benefits you, they let you change code confidently, refactor without fear, and prove to yourself that the fix actually fixed it.
The students who internalize this ship faster. They write a quick test before the implementation, watch it fail, write the implementation, watch it pass, and move on. The whole loop takes minutes, and they have a regression test for free.
The students who treat testing as compliance work end up with brittle, useless tests they hate maintaining, and they're slower than they think because they're running their code by hand to verify changes.
One real test that catches one real bug repays itself 100x in time saved. The first time you refactor confidently because tests have your back, you'll get it.
Practice writing tests on real broken implementations
InternQuest's test track has 25+ missions where you write the unit tests that would have caught the broken code. Real bugs, real test patterns, automated grading. Free.
Try a test mission →