7 min read
Software Engineering Practices Index
Tier 1 -- Foundations
Developer Skills
Testing Basics
Source Control
Tier 2 -- Core Practices
Tier 3 -- Platform & Delivery
Software Engineering Practices Index
Tier 1 -- Foundations
Developer Skills
Testing Basics
Source Control
Tier 2 -- Core Practices
Tier 3 -- Platform & Delivery
Unit Testing
What and Why
A unit test exercises a small, isolated piece of code — typically a single function or method — and asserts that it behaves correctly for a given input. "Isolated" means no database, no network, no filesystem, no external service.
You encounter unit tests as the foundational layer of any test suite. They run in milliseconds, give you precise failure signals, and can catch regressions the instant you make a change — before a PR, before CI, before deployment.
Why they matter in production:
- Fast feedback: a suite of 500 unit tests should finish in under 2 seconds. That tightness of loop changes how you code.
- Refactoring confidence: when behavior is pinned by tests, you can restructure internals without fear of silent breakage.
- Documentation: well-named tests describe what the code is supposed to do. They're often the first thing a new team member reads.
- Regression prevention: bugs fixed without a test tend to reappear. A test turns a bug fix into a permanent contract.
Your team refactors a payment processing module, reorganizing internal helper functions without changing any public behavior. Shortly after merging, a bug surfaces in production that the refactor silently introduced. Which practice would have most directly prevented this outcome?
Core Concepts
What a Unit Is
A unit is the smallest thing you can usefully test in isolation. For a backend service, that usually means:
- A pure function (no side effects, same input → same output)
- A method on a struct/class that manipulates internal state
- A logic layer that doesn't depend on I/O
It does NOT mean testing private implementation details. Test behavior, not internals.
// Good unit: pure function, testable in isolation func calculateDiscount(orderTotal float64, membershipTier string) float64 { switch membershipTier { case "gold": return orderTotal * 0.15 case "silver": return orderTotal * 0.10 default: return 0 } }
// Hard to unit test: tightly coupled to I/O func processOrder(orderID string) error { order, err := db.GetOrder(orderID) // database call baked in // ... }
The fix for the second case is dependency injection — pass the database dependency in so tests can swap it out.
Anatomy of a Test
Every test has three parts, often called Arrange-Act-Assert (AAA) or Given-When-Then:
func TestCalculateDiscount_GoldMember(t *testing.T) { // Arrange — set up inputs and expected state orderTotal := 100.0 membershipTier := "gold" expectedDiscount := 15.0 // Act — call the unit under test actualDiscount := calculateDiscount(orderTotal, membershipTier) // Assert — verify the outcome if actualDiscount != expectedDiscount { t.Errorf("expected %v, got %v", expectedDiscount, actualDiscount) } }
Keep each test focused on one behavior. If your Arrange section is 20 lines, the unit probably has too many responsibilities.
Test Naming
Test names are documentation. They should communicate:
- What is being tested
- Under what condition
- What the expected outcome is
Common pattern: Test<Unit>_<Scenario>_<ExpectedBehavior>
TestCalculateDiscount_GoldMember_Returns15Percent
TestCalculateDiscount_UnknownTier_ReturnsZero
TestParseJWT_ExpiredToken_ReturnsError
TestParseJWT_ValidToken_ReturnsUserClaims
A failing test with a name like TestParseJWT_ExpiredToken_ReturnsError tells you exactly what broke. A test named TestParseJWT or Test3 tells you nothing.
Test Coverage
Coverage measures how much of your code is executed by tests. It's useful as a signal, not a target.
- 100% coverage doesn't mean your code is correct — it means every line ran during tests.
- Low coverage (below ~70%) usually signals undertested logic paths.
- Coverage gaps in critical paths (auth, billing, data integrity) are high-risk.
The dangerous misconception: "we have 90% coverage" → "we're well tested." Coverage says nothing about whether your assertions are meaningful. A test that calls a function and asserts nothing contributes to coverage without providing safety.
Testing Edge Cases
Production bugs cluster at edges:
- Empty inputs, nil/null values
- Boundary values (0, -1, max int, empty string)
- Unexpected types or formats
- Concurrent access
- Error return paths
For every unit, ask: what inputs could cause unexpected behavior? Test those explicitly.
A developer writes a test that calls processPayment(order) and never calls any assertion method — no expected value is checked. The test passes every time. What is the most accurate statement about this test's value?
How It Works in Practice
Go Example
// calculator.go package billing func ApplyTax(amount float64, taxRate float64) float64 { if taxRate < 0 || taxRate > 1 { return amount } return amount + (amount * taxRate) }
// calculator_test.go package billing import "testing" func TestApplyTax_StandardRate_AddsTax(t *testing.T) { result := ApplyTax(100.0, 0.08) if result != 108.0 { t.Errorf("expected 108.0, got %f", result) } } func TestApplyTax_ZeroRate_ReturnsOriginal(t *testing.T) { result := ApplyTax(100.0, 0.0) if result != 100.0 { t.Errorf("expected 100.0, got %f", result) } } func TestApplyTax_NegativeRate_ReturnsOriginal(t *testing.T) { result := ApplyTax(100.0, -0.05) if result != 100.0 { t.Errorf("expected 100.0, got %f", result) } } func TestApplyTax_RateAboveOne_ReturnsOriginal(t *testing.T) { result := ApplyTax(100.0, 1.5) if result != 100.0 { t.Errorf("expected 100.0, got %f", result) } }
Run them:
go test ./billing/... go test -v ./billing/... # verbose — shows test names go test -run TestApplyTax ./... # run specific test go test -cover ./... # show coverage
Python Example (pytest)
# billing.py def apply_tax(amount: float, tax_rate: float) -> float: if tax_rate < 0 or tax_rate > 1: return amount return amount + (amount * tax_rate)
# test_billing.py import pytest from billing import apply_tax def test_apply_tax_standard_rate_adds_tax(): assert apply_tax(100.0, 0.08) == pytest.approx(108.0) def test_apply_tax_zero_rate_returns_original(): assert apply_tax(100.0, 0.0) == 100.0 def test_apply_tax_negative_rate_returns_original(): assert apply_tax(100.0, -0.05) == 100.0 @pytest.mark.parametrize("rate,expected", [ (0.08, 108.0), (0.0, 100.0), (-0.05, 100.0), (1.5, 100.0), ]) def test_apply_tax_parametrized(rate, expected): assert apply_tax(100.0, rate) == pytest.approx(expected)
pytest pytest -v # verbose pytest -k "test_apply_tax" # filter by name pytest --cov=billing # coverage report
Table-Driven Tests (Go)
Table-driven tests reduce boilerplate when testing one function across many inputs:
func TestApplyTax(t *testing.T) { tests := []struct { name string amount float64 taxRate float64 expected float64 }{ {"standard 8% rate", 100.0, 0.08, 108.0}, {"zero rate", 100.0, 0.0, 100.0}, {"negative rate", 100.0, -0.05, 100.0}, {"rate above one", 100.0, 1.5, 100.0}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := ApplyTax(tc.amount, tc.taxRate) if result != tc.expected { t.Errorf("expected %f, got %f", tc.expected, result) } }) } }
Adding a new test case is one line. Failure output names the case that failed.
A Go developer has written four separate test functions for a CalculateDiscount function, each testing a different input scenario (standard rate, zero rate, negative rate, rate above maximum). She wants to add five more edge cases. Which approach would best reduce boilerplate while keeping failure output descriptive enough to identify which specific case failed?
Common Mistakes
Testing implementation details instead of behavior. If your test breaks every time you rename a private variable or change an internal data structure, the tests are coupled to the wrong thing. Test what the function returns and what side effects it produces — not how it does it internally.
Asserting too much in one test. A test that checks 5 different behaviors will fail with an ambiguous message. One assertion per test (or one logical behavior per test) gives you precise failure signals.
Not testing error paths. Most unit tests only cover the happy path. Error branches — invalid inputs, missing values, unexpected formats — are where bugs live. Test err != nil returns explicitly.
Flaky tests that depend on time or randomness. Tests that use time.Now() or rand directly will fail intermittently. Inject clocks and random sources as dependencies so tests can control them.
Coverage gaming. Writing tests that execute lines without asserting anything meaningful inflates coverage numbers but provides no safety net. "Tests that don't fail when they should" are worse than no tests — they give false confidence.
A test suite for a payment service has 95% line coverage. A developer notices that many tests call functions but never assert on return values or state changes — they only verify the code doesn't panic. What is the most accurate assessment of this test suite?
Tradeoffs
Speed vs. realism. Unit tests are fast because they're isolated. But isolation means you can't catch bugs that only appear when components interact. Unit tests and integration tests serve different purposes — don't treat unit tests as a complete substitute.
Test quantity vs. maintenance cost. More tests means more code to maintain. Tests tied to internal structure become a drag during refactors. Keep tests focused on stable behavior (the public API) and you can refactor freely beneath them.
Strict isolation vs. pragmatism. Some engineers insist every unit test must mock all dependencies. In practice, calling real pure helper functions in tests is fine — the goal of isolation is to avoid I/O and non-determinism, not to prohibit all composition.
TDD vs. test-after. Writing tests first (TDD) pushes you toward better-designed interfaces. Writing tests after is faster initially but often results in code that's harder to test. Both approaches produce tests — the difference is in design influence.
A team has a large codebase with hundreds of unit tests that mock every internal helper function, including pure utility functions with no I/O or side effects. During a major refactor, the team finds they must update dozens of tests even though the public behavior hasn't changed. What is the most likely root cause of this maintenance burden?
Quick Reference
Structure of a test:
Arrange → set up inputs and expectations
Act → call the unit under test
Assert → verify the outcome
Test name pattern:
Test<Unit>_<Condition>_<ExpectedBehavior>
What to test:
✓ Happy path (expected input → expected output)
✓ Edge cases (empty, nil, zero, max values)
✓ Error paths (invalid input → error returned)
✓ Boundary conditions
What NOT to test:
✗ Private internals / implementation details
✗ Third-party library behavior
✗ Framework wiring (that's integration testing)
Run commands:
Go: go test ./... # run all tests
go test -cover ./... # with coverage
Python: pytest # run all tests
pytest --cov # with coverage
Java: mvn test / gradle test
Node: jest / vitest
A developer writes a unit test that verifies how a third-party payment library processes a credit card charge. According to standard unit testing practices, what is the problem with this approach?
Glossary History
Click dotted jargon to save explanations here.
Glossary History
Click dotted jargon to save explanations here.