Module 8: Unit Testing Fundamentals
Module 8: Unit Testing Fundamentals
Course Overview
| Field | Details |
|---|---|
| Module Number | Module 8 |
| Module Name | Unit Testing Fundamentals |
| Status | Content Drafted — syllabus/exam details pending |
| Study Duration | [ To be defined ] |
| Exam Format | Live coding / explanation via recorded Teams call |
| Attempts | Up to 3 per part |
| OneDrive Link | [ Add OneDrive link when available ] |
Introduction
A unit test is a small, automatic check. It checks that one small piece of your code — usually a single function or method — does what it’s supposed to do.
Think about how a car factory tests parts. Before the whole car is built, they test the brakes on their own. They test the engine on its own. They don’t wait until the whole car is assembled to find out the brakes don’t work. Unit tests do the same thing for code: they check one small piece, on its own, before everything is put together.
A unit test doesn’t need a real database, a real website, or a real network connection. It runs the one piece of code, checks the result, and tells you “pass” or “fail.” That’s it.
Unit tests are the smallest and fastest kind of test. Above them sit integration tests (checking that two or more pieces work correctly together, like the engine and the fuel system) and end-to-end tests (checking the whole finished car actually drives). You should have many unit tests, fewer integration tests, and only a few end-to-end tests — because unit tests are the cheapest and quickest to write and fix.
This module doesn’t focus on one programming language. The ideas here apply whether you write JavaScript, Python, Java, C#, or anything else. Use whatever testing tool exists for your language (for Node.js, that’s usually Jest or Mocha) to put these ideas into practice.
Why It’s Important
- You find out fast if something breaks. A good set of unit tests runs in seconds. You know right away if your change broke something, instead of finding out days later.
- You can change code without fear. If you have tests, you can improve or rewrite code and immediately know if you broke anything. Without tests, every change is a gamble.
- Tests explain what the code should do. A well-written test reads almost like a sentence: “when the cart is empty, the total should be zero.” Anyone reading it understands the intended behaviour, and if that behaviour ever changes without updating the test, the test will fail and flag it.
- Tests push you toward better code. Code that’s hard to test is usually badly designed — too tangled, doing too many things at once. Writing tests forces you to break code into smaller, cleaner pieces.
- Bugs are cheaper to fix early. A bug caught by a test costs you a few minutes. The same bug found by a customer in production can cost hours of investigation, or worse.
Uncle Bob References
Robert C. Martin, known as “Uncle Bob,” is one of the most well-known voices on how to write good tests. He wrote the book Clean Code and has a well-known blog. Two of his ideas come up constantly in the industry, so it’s worth knowing them by name.
The Three Laws of TDD
From his article “The Cycles of TDD”:
- You’re not allowed to write any real code until you’ve written a test that fails.
- You’re not allowed to write more of the test than what’s needed to make it fail.
- You’re not allowed to write more real code than what’s needed to make that one test pass.
In plain words: write a small test, watch it fail, write just enough code to pass it, then repeat. Small steps, over and over, instead of writing a big pile of code and testing it all at the end.
F.I.R.S.T. Principles
From the testing chapter in Clean Code, good tests should be:
- Fast — a slow test is a test people stop running. If tests are slow, people skip them, and bugs slip through.
- Independent — one test should never depend on another test running first, or on data left behind by a different test.
- Repeatable — the test should give the same result every time, on any computer, at any time of day. It shouldn’t depend on the internet being up, or today’s date, or something left over from yesterday.
- Self-validating — the test should just say “pass” or “fail.” Nobody should have to read through a log file to figure out if it worked.
- Timely — write the test around the same time as the code, not weeks later (or never).
Types of Unit Testing
There isn’t just one way to write a unit test. Here are the common approaches:
- Checking the result (state-based testing). Run the code, then check what it returned or what changed. Example: call a function that adds two numbers, and check the answer is correct. This is the most common and simplest approach.
- Checking what happened (behaviour-based testing). Instead of checking the final result, you check that the code called the right things in the right way. Example: checking that a “send email” function was actually called once, with the right address, even if you don’t have a real email system running. This usually relies on mocks — explained further down.
- Testing the edges. Deliberately test tricky inputs: an empty list, zero, a negative number, a missing value, or the biggest number allowed. Bugs love to hide at the edges.
- Testing many examples at once. Instead of writing ten almost-identical tests, write one test and run it against a table of different inputs and expected answers.
- Testing a bug you already found. When you find a bug, first write a test that fails because of that bug. Then fix the bug. Now that bug can never silently come back without the test catching it.
- Testing a rule instead of an example. Instead of checking one specific input/output pair, you check that a rule always holds true — for example, “sorting a list twice gives the same result as sorting it once” — and let the testing tool try lots of random inputs to see if it can break the rule.
A simple way to remember the difference: a unit test asks “does this one small piece work on its own?” An integration test asks “do these pieces work together?” An end-to-end test asks “does the whole thing work for a real user?”
In-Memory Databases
Sometimes the code you’re testing needs to talk to a database. Setting up a real database just to run a quick test is slow and annoying. So teams often use a lightweight, temporary database that lives only in memory (RAM) while the test runs, and disappears afterwards.
Think of it like practising a presentation in front of a mirror before giving it to a real audience. It’s faster and lower-stakes than the real thing — but a mirror can’t ask you a tricky question the way a real audience can.
Why teams like this approach:
- It’s fast — no need to set up or connect to a real database server.
- You can write real data in and read it back out, which feels more realistic than pretending with mocks.
- It works offline, so tests can run anywhere, including automated pipelines.
Why to be careful:
- These fake databases usually don’t behave exactly like your real one. For example, they might not enforce the same rules (like “this field must be unique” or “this reference must point to something real”).
- The commands (“queries”) that work on the fake database might not work exactly the same way on your real database. A test can pass here and still fail in production.
- Because it feels close enough to the real thing, teams sometimes stop writing proper tests against the actual database — which creates false confidence.
Rule of thumb: an in-memory database is a decent shortcut for quick tests, but it is not a full replacement for testing against your real database at least some of the time.
Mocking
Sometimes the code you’re testing depends on something slow, unreliable, or not yet built — a payment system, a real database, today’s date, a third-party website. A mock is a fake, stripped-down stand-in for that thing, used only during the test.
Think of a mock like a stunt double in a movie. The stunt double looks like the actor and does the dangerous scene, so the real actor doesn’t have to. In testing, the mock stands in for the real, risky, or slow dependency, so your test doesn’t have to touch it.
There are a few flavours of “fake object” used in testing, and it helps to know the names:
- Dummy — an object you have to pass in, but it’s never actually used for anything. It’s just there to fill a gap.
- Stub — gives back a fixed, canned answer whenever it’s called, but doesn’t keep track of anything.
- Fake — a simplified but genuinely working version of the real thing (like the in-memory database above).
- Spy — like a stub, but it also remembers how it was used, so you can check later (“was this called? how many times? with what?”).
- Mock — set up in advance with expectations about how it should be used, and the test fails if those expectations aren’t met.
Why bother mocking? To keep tests fast and reliable by avoiding real networks, real databases, the real clock, or real third-party services — things that make tests slow, flaky, or hard to set up.
When to be careful:
- If you mock too much, your test can pass even though the real system is broken — you’ve only proven the fake object works, not the real one.
- If a piece of code needs a dozen mocks just to test it, that’s usually a sign the code is doing too many things and should be split up.
A real-world way to think about it (no code needed): imagine testing “does this app charge the customer correctly?” You don’t want your test to actually charge a real credit card every time it runs. So you swap in a fake payment system that always says “payment succeeded” (or “payment failed,” for the other test) and check that your code reacted correctly in each case — without ever touching a real card.
Test-Driven Development (TDD)
TDD is a way of working, not just a way of testing. The idea: write a test for something before you write the code for it. It was made popular by Kent Beck in the late 1990s and later described in his book Test-Driven Development: By Example. The whole point, in Beck’s own words, is to remove fear from programming — at any moment, you know exactly how much of your program works, because you just watched a test prove it.
The Cycle: Red → Green → Refactor
- Red — write a small test for something that doesn’t exist yet. Run it. It should fail. (If it passes right away, either the test is wrong, or you already built this.)
- Green — write the simplest, quickest code you can to make that one test pass. It’s okay if it’s a bit ugly or hacky at this point — you’re not finished yet.
- Refactor — now that you have a passing test protecting you, clean up the code. Remove duplication, make it clearer. Run the tests again to make sure you didn’t break anything.
Then repeat, adding one small piece of behaviour at a time.
The easy-to-miss detail: most of the actual design thinking happens during the Refactor step, not while you’re rushing to make the test pass. It’s fine for the “Green” code to be messy — Refactor is where it gets tidy.
A real-world way to picture the cycle: say you’re building a login feature, one small step at a time. First you check “does it reject an empty password?” (a small test). You add just enough logic to make that check pass (green). Then you clean up that bit of logic so it’s clear and not duplicated (refactor). Next you check “does it reject a wrong password?” — a new, slightly bigger check — and repeat. You never try to build the whole login system in one go; you check and build it one small rule at a time, and by the end all the individual checks add up to a working, well-tested feature.
Two Schools of TDD
Not everyone practises TDD the exact same way. It helps to know there are two common styles:
- Inside-out (sometimes called “Detroit” or “classical”). You start from the core logic and build outward, using real pieces of your own code where you reasonably can, and only faking things that are genuinely outside your control (the network, the clock, an external service). The design is allowed to take shape gradually as you go.
- Outside-in (sometimes called “London” or “mockist”). You start from the outside — for example, “what should happen when a user clicks submit?” — and fake every piece underneath that doesn’t exist yet. This lets you design how the pieces should talk to each other before you’ve built them, and you check that the right calls happened rather than checking the final result.
Neither one is “the correct way.” They’re just different starting points, and many people mix both depending on what they’re building.
Why Teams Use It — and Why Some Don’t
The claimed benefits: you get quick feedback, your tests double as a description of what the code should do, and it naturally pushes you toward smaller, simpler pieces of code (because tangled code is hard to test).
The honest criticisms: strict test-first can take time to get used to. It doesn’t suit every situation — for example, when you’re exploring an idea you don’t fully understand yet, or working on something very visual like a user interface. And if you fake too much (see “Mocking” above), your tests can become fragile — breaking every time you tidy up code, even when the actual behaviour hasn’t changed.
A sensible middle ground: many experienced developers use TDD by default for logic they understand well — calculations, business rules, data validation — but allow themselves to first try a rough, throwaway version of something unfamiliar before writing proper tests around the real solution.
Common Mistakes to Avoid
- Testing how the code is written, instead of what it does. If your test breaks every time you tidy up the code — even though the actual behaviour hasn’t changed — the test is too tied to implementation details.
- Skipping the tidy-up step. If you always stop as soon as the test passes and never clean up, you lose most of the benefit of TDD.
- Trying to test too much in one go. If one test is driving pages and pages of new code before it passes, break the problem into smaller steps.
- Chasing 100% test coverage as the goal. High coverage with weak or pointless tests (like testing that a variable stores what you just gave it) gives false confidence. Coverage is a side effect of good testing, not the target.
Martin Fowler describes TDD simply as “a technique for building software that guides software development by writing tests” — the design grows naturally out of the process of making tests pass, rather than being fully planned out beforehand. See his bliki entry on TDD for more.
Links
Uncle Bob (Robert C. Martin)
Martin Fowler
- Test Driven Development (bliki)
- Is TDD Dead? (conversation series)
- Mocks Aren’t Stubs
- The Practical Test Pyramid
- Test Pyramid (bliki)
- Software Testing Guide (index)
Test-Driven Development — Deeper Reading
- Kent Beck’s original TDD paper (PDF)
- Notes on “Test-Driven Development by Example” by Kent Beck
- Test Driven Development Wars: Detroit vs London, Classicist vs Mockist
- Detroit and London Schools of Test-Driven Development
In-Memory Databases
- Avoid In-Memory Databases for Tests — Jimmy Bogard
- Using in-memory databases for unit testing EF Core applications — The Reformed Programmer
- Using H2 as an in-memory database for unit/component tests
Study Resources
- Robert C. Martin, Clean Code — Chapter 9: Unit Tests
- Kent Beck, Test-Driven Development: By Example
- Gerard Meszaros, xUnit Test Patterns: Refactoring Test Code (source of the dummy/stub/fake/spy/mock vocabulary)
Exam Preparation — Questions & Answers
Use these to test yourself after your two weeks of study. Try to answer each one out loud or in writing, from memory, before checking the answer below it.
Q1. What is a unit test, and how is it different from an integration or end-to-end test? A unit test checks one small piece of code — like a function or method — on its own, without needing a real database, network, or user interface, and gives a clear pass or fail result. An integration test checks that several pieces work correctly together. An end-to-end test checks that the whole system works for a real user. Most of your tests should be unit tests, because they’re the fastest and easiest to fix when something breaks.
Q2. Why do unit tests matter? Give at least three reasons. Any three of: you find out quickly if you broke something; you can change or improve code with confidence, because tests will catch mistakes; tests describe what the code should do and can’t quietly go out of date; hard-to-test code is usually a sign of bad design, so tests push you toward cleaner code; and bugs caught early are much cheaper to fix than bugs found in production.
Q3. State Uncle Bob’s Three Laws of TDD.
- Don’t write real code until you’ve written a test that fails.
- Don’t write more of the test than what’s needed to make it fail.
- Don’t write more real code than what’s needed to make that one test pass.
Q4. What does F.I.R.S.T. stand for, and why does each part matter? Fast (slow tests get skipped, so bugs slip through), Independent (no test should rely on another test running first), Repeatable (same result every time, on any computer), Self-validating (a clear pass/fail, not something you have to interpret), Timely (written around the same time as the code, not much later).
Q5. Name and briefly describe three different types of unit testing. Any three of: checking the result (state-based) — run the code and check what it returns; checking what happened (behaviour-based) — check the code called the right things correctly, usually using mocks; testing the edges — empty values, zero, negatives, missing values, maximums; testing many examples at once with a table of inputs and expected outputs; testing a bug you found, by writing a failing test for it before fixing it; testing a rule that should always hold true, across many random inputs.
Q6. What’s the difference between checking the result and checking what happened? Checking the result (state verification) looks at the final value or state after running the code. Checking what happened (behaviour verification) looks at whether the code called its dependencies correctly — which methods, how many times, with what values. The first is simpler and more common; the second usually needs mocks.
Q7. What are the pros and cons of using an in-memory database in tests? Pros: it’s fast, avoids setting up a real database, and lets you write and read real data during a test. Cons: it often doesn’t enforce the same rules as your real database (like uniqueness or required references), the commands it understands might not exactly match your real database, and relying on it too much can create false confidence that your code actually works against the real database.
Q8. List the five kinds of fake test objects and what makes each one different. Dummy — passed in but never actually used. Stub — gives a fixed, canned answer, but doesn’t track anything. Fake — a simplified but genuinely working version of the real thing. Spy — like a stub, but it also remembers how it was called so you can check later. Mock — set up in advance with expectations about how it should be used, and fails the test if those expectations aren’t met.
Q9. What can go wrong if you mock too much? Your test can pass even though the real system is actually broken, because you’ve only proven the fake version works — not the real one. Also, if one piece of code needs many mocks just to test it, that’s usually a sign it’s doing too many things and should be split up.
Q10. What are the three steps of the TDD cycle, and what happens in each one? Red — write a small test for something that doesn’t exist yet; it should fail. Green — write the simplest code that makes it pass, even if it’s a bit messy. Refactor — clean up the code now that you have a passing test protecting you, then re-run the tests to make sure nothing broke. Most of the real design thinking happens in Refactor, not Green.
Q11. What’s the difference between the “inside-out” and “outside-in” styles of TDD? Inside-out starts from the core logic and builds outward, using real pieces where possible and only faking things truly outside your control. Outside-in starts from the outside (like what should happen when a user clicks a button) and fakes everything underneath that doesn’t exist yet, designing how the pieces should talk to each other before building them. Neither is “correct” — many people mix both.
Q12. What are some honest criticisms of strict TDD, and when might a team reasonably not use it? It can take time to get used to, it doesn’t suit every situation (like exploring an idea you don’t understand yet, or heavily visual work), and if you fake too much, tests can become fragile and break even when real behaviour hasn’t changed. A sensible middle ground: use TDD for logic you understand well, but allow yourself to try a rough, throwaway version first when exploring something new.
Q13. Name three common mistakes people make with TDD. Any three of: testing how the code is written instead of what it does (making tests fragile); skipping the clean-up (refactor) step; trying to test too much in one go instead of small steps; and chasing 100% test coverage as the goal instead of writing meaningful tests.
Q14. Why are unit tests sometimes called “living documentation”? Because a well-written test describes, in plain terms, what the code is supposed to do — and unlike a comment or a document, it can’t quietly become outdated. If the behaviour changes and nobody updates the test, the test will fail and force someone to notice.
Q15. Using the login-feature example from this module, explain why TDD builds things gradually instead of all at once. Because each TDD cycle only tackles one small piece of behaviour at a time — like checking that an empty password is rejected before worrying about a wrong password. You check and build one small rule, get it working, tidy it up, then move to the next rule. You never try to build the whole feature in a single attempt; the overall design comes together gradually, step by step, and by the end the individual checks add up to a working, well-tested feature.
Videos & Course Material
Grads: add your own video links here via PR! See Contributing.
| Topic | Video | Contributed by |
|---|---|---|
| add topic | add YouTube link | your name |