Testability and design
Quick recap
- How to derive test cases?
- Specification testing
- Boundary testing
- Structural testing
The claim
Everything that follows argues for two sentences:
- Untestable code is a design smell. The difficulty is the symptom. The defect is in the design.
- Making code testable is not the same as improving it. You can always force a test to become possible. That usually leaves the design worse.
The first is easy to accept. The second is where the work is, so start there.
Testability and design
- Iceberg class — one method above the waterline, five below.
| RuleEvaluator |
|---|
| + evaluate(rule) |
| - _parse_adjunct_term() |
| - _parse_factor_term() |
| - _parse_adjunct() |
| - _has_more_tokens() |
| - _get_next_token() |
It evaluates rule strings like "active AND premium OR trial". The leading _ is
Python's marker for private — the same thing the - says in the diagram:
class RuleEvaluator:
def evaluate(self, rule): # the only way in
self.source = rule
self.position = 0
return self._parse_adjunct()
def _has_more_tokens(self): ... # skip separators, anything left?
def _get_next_token(self): ... # read up to the next separator
def _parse_adjunct(self): ... # OR level
def _parse_adjunct_term(self): ... # AND level
def _parse_factor_term(self): ... # a single term
Testability: not good.
Why not
Five of the six methods are private. The only door into this class is
evaluate(rule), and the only thing that comes back is one boolean.
# the test you want
assert tokens_of("active\tAND\tpremium") == ["active", "AND", "premium"]
# the test you can write
assert RuleEvaluator().evaluate("active\tAND\tpremium") is False
The second assertion is worth almost nothing:
- When it fails, you do not know why. A tokenizer that does not treat a tab as a
separator fails it. So does an
ANDthat got its precedence wrong. So does a typo in_parse_factor_term. One boolean cannot separate five methods. - When it passes, it does not tell you the tokenizer is right. It tells you the whole pipeline happened to agree on this one string.
To corner a tokenizer bug you must invent rule strings until one of them makes the final answer flip — reasoning backwards through four other methods to design each input.
The pain is not that the tests are hard to write. It is that they cannot be aimed.
Fix 1
Make private function public. Testable? Yes. Good design?
| RuleEvaluator |
|---|
| + evaluate(rule) |
| - _parse_adjunct_term() |
| - _parse_factor_term() |
| - _parse_adjunct() |
| + has_more_tokens() |
| - _get_next_token() |
class RuleEvaluator:
def evaluate(self, rule):
self.source = rule
self.position = 0
return self._parse_adjunct()
def has_more_tokens(self): ... # underscore dropped. now public
def _get_next_token(self): ...
The test is now possible — and look at what it costs:
e = RuleEvaluator()
e.evaluate("active\tAND\tpremium") # must run first: it sets self.source
assert e.has_more_tokens() is False
You cannot call the public method until you have called the other public method. The class now advertises that it holds a parse position, and every client — not only the test — is free to read it mid-parse.
💬 It is testable now. Is it better?
Fix 1 cost one keyword and bought a test. Name what the class gave up.
Answer
Three things, none of which the test needed.
- A promise it cannot keep.
has_more_tokens()is public, so it is part of the contract now. Tokenizing can no longer change without breaking callers you have never met. - An object that is only half usable. Call it before
evaluate()and it reads a position nobody set. The class has acquired an order of operations and no way to state it. - Nothing on the diagnosis. The test still cannot ask "does this tokenizer split on tabs?" It can only watch a parse already in flight.
Fix 1 is available for every untestable class you will ever meet. A private method made public, a field exposed, a setter added for tests only. The suite goes green every time, and the design is worse every time.
Root cause
- Code violates Single Responsibility Principle
| RuleEvaluator |
|---|
| + evaluate(rule) |
| - _parse_adjunct_term() |
| - _parse_factor_term() |
| - _parse_adjunct() |
| + has_more_tokens() |
| - _get_next_token() |
Highlighted: the one thing this class was asked to do. Bold: parsing. Italic: tokenizing. Two jobs in one box.
| RuleEvaluator |
|---|
| + evaluate(rule) |
| - _parse_adjunct_term() |
| - _parse_factor_term() |
| - _parse_adjunct() |
| RuleTokenizer |
|---|
| + has_more_tokens() |
| + get_next_token() |
class RuleTokenizer:
def __init__(self, source): # arrives complete
self.source = source
self.position = 0
def has_more_tokens(self): ...
def get_next_token(self): ...
class RuleEvaluator:
def evaluate(self, rule):
self._tokens = RuleTokenizer(rule) # held privately
return self._parse_adjunct()
def _parse_adjunct(self): ...
def _parse_adjunct_term(self): ...
def _parse_factor_term(self): ...
And the test you actually wanted, three slides ago:
t = RuleTokenizer("active\tAND\tpremium")
assert [t.get_next_token() for _ in range(3)] == ["active", "AND", "premium"]
No rule string to reverse-engineer, no evaluate() call to prime the object, and a
failure names exactly one method.
- The
RuleTokenizerclass now has a clean, public interface (has_more_tokens,get_next_token), making it independently testable.- The original
RuleEvaluatorholds a private instance of theRuleTokenizer, maintaining its external encapsulation. - This new structure adheres to SRP, resulting in smaller, more focused, and more maintainable classes. The testability problem disappears as a side effect of improving the design.
- The original
What that sequence showed
- Fix 1 made the class testable and made it worse. The test could now reach
has_more_tokens()— and so could every other client. - Extracting
RuleTokenizermade it testable and made it better. Nobody was aiming at testability; it was an SRP fix, and the testability arrived as a side effect.
Both routes end at "now I can test it". Only one of them is a fix.
Seven testing pains, and what each one is really telling you
Every pain below is a complaint about the design. The flaw in the middle column already had a name before unit testing existed; the right-hand column is the rest of this lecture.
| # | The testing pain | The design flaw underneath | Fix |
|---|---|---|---|
| 1 | Difficult setup — instantiating one class drags in half the application | Excessive coupling | smaller, cohesive units with fewer dependencies |
| 2 | State leaks — tests fail intermittently, or poison the next test | Global mutable state, unmanaged lifecycle | own your resources; no singletons |
| 3 | Framework frustration — logic welded to a GUI, web or persistence framework | Insufficient separation of concerns | §1 — infrastructure vs domain |
| 4a | Difficult mocking — a fake returns a fake that returns a fake | Law of Demeter — a.getB().getC().getD() |
shorten the chain |
| 4b | Difficult mocking — the dependency cannot be substituted at all | Dependency Inversion — you depend on a concrete class | §2 — dependency injection |
| 5 | Hidden inputs and effects — input arrives from nowhere, effects vanish into a database | Encapsulation violation; the class does more than it says | §1 — separate computation from I/O |
| 6 | Unwieldy parameter lists — twelve arguments to call one method | Too many responsibilities | split the class |
| 7 | Test thrash — one small change breaks thirty tests | Open/Closed violation, poor locality | extend by substitution, not modification |
💬 Which row is your snake game?
Open your Lab 1 repository. Pick the function you would least like to write a test for, and find its row in the table above.
Answer
Most of you will land on the same handful.
| If your worst function… | Row | Because |
|---|---|---|
calls getch() or rand() inside the game rules |
5 | the input arrives from somewhere no test can reach |
| reads or writes a global board, score or direction | 2 | two tests in one binary now share state |
| draws the frame and decides the rules in one pass | 3 | you cannot have the rule without the screen |
is main(), or something main()-shaped at 200 lines |
1 and 6 | nothing can be constructed on its own |
Not one of those rows says "add a test". Each names a change to the production code you would want anyway, and the test becomes possible as a consequence.
Designing for Testability
1. Separate infrastructure code from domain code (at architectural level)
The rule cannot run without a database
class InvoiceFilter:
def _all(self): # infrastructure
conn = DatabaseConnection("invoices_db", "root")
invoices = conn.execute_query("SELECT * FROM invoices")
conn.close()
return invoices
def low_value_invoices(self): # the business rule
result = []
for inv in self._all(): # <- cannot run without a database
if inv.value < 100:
result.append(inv)
return result
The rule is "an invoice below 100 is low value". Four lines above it decide that you cannot ask that question without a live connection.
Known issues:
- Domain code and infrastructure code are mixed. This means we will not be able to avoid database access when testing the low-value invoices rule.
- The more responsibilities, the more complexity, and the more chances for bugs. Classes that are less cohesive contain more code.
What a seam is
Michael Feathers named this in Working Effectively with Legacy Code:
A seam is a place where you can alter behaviour in your program without editing in that place.
Two halves, and the second is the one people miss:
- the seam — where the substitution takes effect
- the enabling point — where you choose what to substitute
A seam with no enabling point is not a seam. If the only way to swap the database is
to edit _all(), the test cannot do it.
Not every call is a seam
This is the test, and it is why the enabling point is in the definition.
class Checkout:
def confirm(self, order):
gateway = StripeGateway(api_key=LIVE_KEY) # the class is decided here
gateway.charge(order.total) # ...so this is not a seam
You have a very good reason to want that last line replaced: running this test charges
a real card. And you cannot. charge is a method call on an object, it looks
substitutable, and it is not — the concrete class was fixed one line above, inside the
method. Nothing outside can reach it.
The only ways out are to edit confirm, or to ship a test-mode flag into production
code. Both change the program to suit the test.
class Checkout:
def __init__(self, gateway): # <- enabling point
self._gateway = gateway
def confirm(self, order):
self._gateway.charge(order.total) # ...and now it is a seam
The call did not change. What changed is that somewhere else can now decide. A
parameter on confirm would work just as well; what matters is only that the decision
moved outside the method.
The test that was impossible one slide ago
from unittest.mock import Mock
def test_confirm_charges_the_order_total():
gateway = Mock() # charges nothing, records everything
checkout = Checkout(gateway) # modify behavior via mock
checkout.confirm(Order(total=250))
gateway.charge.assert_called_once_with(250) # the test asserts the call, not the return value
Four lines, and every one of them was unavailable before the seam existed:
- no card, no API key, no network. The test runs in microseconds
- it asserts the amount. The real gateway would only ever have told you it succeeded
- it asserts once. Charge twice and the test fails:
Expected 'charge' to be called once. Called 2 times. - nothing in
Checkoutchanged to make this possible — the production path still constructs a realStripeGatewayand still charges real cards
charge is a command: it changes the world and returns nothing worth reading. You
cannot check a command by its return value. You check it by watching the call.
💬 Seam or not? And if it is, where is the enabling point?
Five lines. For each one: can behaviour there be replaced without editing there — and if so, where does the decision live?
| Code | Seam? | Enabling point | |
|---|---|---|---|
| a | conn = psycopg2.connect(...) then conn.execute(sql) |
||
| b | def run(self, conn): conn.execute(sql) |
||
| c | datetime.now(), called inside the method |
||
| d | self._clock.now(), clock set in __init__ |
||
| e | requests.get(url), module imported at the top |
Answer
| Seam? | Enabling point | |
|---|---|---|
| a | no | none — the connection was built one line up, in the same scope |
| b | yes | the argument list |
| c | no | none — the call reaches straight out to the clock |
| d | yes | the constructor call |
| e | yes, technically | the module attribute — somebody else reassigns requests.get |
(e) is the one that should bother you. It is a seam, and a bad one. The enabling point is "some other module reached in and reassigned an attribute" — nothing at the call site says so. That is the same objection as the link seam, in a language with no linker.
Note that c and d are the same call. The only difference is whether anybody else was given a chance to decide.
Three kinds, in descending order of dignity
Feathers classifies them by what does the swapping:
| Kind | Swapped at | Cost |
|---|---|---|
| Object seam | run time, by passing a different object | cheap, reversible, the one you want |
| Link seam | build time, by linking a different binary | whole-binary granularity |
| Preprocessing seam | compile time, by #define |
last resort |
A design offers object seams. A desperate test manufactures the other two.
Watch for the third one when you read somebody's tests. It is a confession.
One call, three seams
One line is the whole problem. send_email talks to a mail server; a test must not.
void confirm_order(const char *customer) {
save(customer);
send_email(customer, "Your order is confirmed"); /* <- this */
}
Three ways to stop it, and none of the three edits this function.
Seam 1: preprocessing
Replace the text before the compiler ever sees it.
/* testdefs.h */
#ifdef TESTING
extern const char *last_to;
#define send_email(to, body) (last_to = (to))
#endif
void send_email(const char *to, const char *body);
#include "testdefs.h" /* after the declaration, never before */
| Seam | the send_email call |
| Enabling point | -DTESTING, a compiler flag |
Put the include above the declaration and the macro rewrites the declaration too, and nothing compiles. A seam you can install backwards is a seam you will install backwards.
Seam 2: link
Leave the call alone. Give the linker a different function to resolve it to.
/* stub_mailer.c — compiled into the test build in place of mailer.c */
void send_email(const char *to, const char *body) { }
cc order.c mailer.c -o app # production
cc order.c stub_mailer.c -o tests # test
| Seam | the unresolved send_email reference |
| Enabling point | the build script |
Nothing in the source says any of this is happening. Read order.c all day and you
will not learn that the mail is fake.
Seam 3: object
Stop calling a free function. Take the mailer as an argument.
void confirm_order(const string &customer, Mailer &mailer) {
save(customer);
mailer.send(customer, "Your order is confirmed");
}
| Seam | mailer.send(...) |
| Enabling point | the argument list |
The same three, side by side
| Enabling point | Granularity | Visible in the source? | |
|---|---|---|---|
| Preprocessing | -DTESTING |
whole build | no |
| Link | the build script | whole binary | no |
| Object | the argument list | per test | yes |
Only the third lets one test binary hold two different mailers. The first two are decided once, for everything, somewhere the reader is not looking.
The question is never whether a seam exists. It is which one you can afford to live with.
Note what Python, Java and C# do not have: a preprocessor, and a linker you can point elsewhere. Two of these three rows are a C and C++ inheritance. The one language family that gives you extra seams is the one that most needs them.
💬 Make this one testable. Change behaviour by nothing.
class OrderService {
public:
void confirm(const string &customer) {
save(customer);
send_email(customer, "Your order is confirmed"); // free function
}
};
You may not rewrite confirm. You may not change what the program does. Add as
little as you can get away with, and say where the enabling point ends up.
Answer
Add a method to the class with the same signature as the free function, and have it forward:
class OrderService {
public:
void confirm(const string &customer) {
save(customer);
send_email(customer, "Your order is confirmed"); // now resolves to the member
}
protected:
virtual void send_email(const string &to, const string &body) {
::send_email(to, body); // the real one
}
};
confirm is untouched. The call now resolves to the member rather than the free
function, and the member does exactly what the free function did. Behaviour is
identical. Then subclass it in the test and override send_email to do nothing.
- Seam — the
send_emailcall, unchanged. - Enabling point — which class the test instantiates.
The same trick has a second form: a private static method becomes overridable by
dropping static and widening it to protected. Both are one-line changes that
create an enabling point where there was none.
You do not need the design to be right. You need one place where the decision can move.
💬 One of you wrote this. Where is the seam?
From dudhatmonar/snake-game-cpp:
void saveHighScore() {
if (score > highScore) {
ofstream outFile(HIGH_SCORE_FILE);
if (outFile.is_open()) {
outFile << score;
outFile.close();
highScore = score;
}
}
}
Three questions, in order:
- What rule does this function implement?
- Where is the enabling point today?
- One branch here cannot be reached by any test. Which, and why?
Answer
1. The rule. A high score is recorded only when it is beaten. An equal score
must not overwrite. Whether that should be > or >= is a real boundary
question — and nobody can settle it with a test today.
2. There is none. ofstream outFile(HIGH_SCORE_FILE) builds the collaborator
inside the method, and HIGH_SCORE_FILE is a file-scope const string.
Construction in the same scope, so nothing outside gets to decide — the same
shape as StripeGateway(api_key=LIVE_KEY).
The cost is immediate: the test writes score.txt into the working directory, and
the second test reads whatever the first one left there.
3. if (outFile.is_open()) has no else. When the file will not open,
highScore = score never runs. The object keeps the old value while the caller
believes the save succeeded. To reach that branch you must make a write fail on
demand, and you cannot make a real ofstream fail on demand.
One way to install the seam:
void saveHighScore(ScoreStore &store) { // <- enabling point
if (score > highScore) {
if (store.write(score)) {
highScore = score;
}
}
}
A fake ScoreStore can now refuse the write. The argument for the seam was
never convenience. That branch is already in the code, already wrong, and
currently unreachable.
💬 This author injected four things. Name the fifth.
From divyesh-dandwani/Snake-Game-CPP:
Point generateFood(int w, int h, const vector<Point> &snake, const vector<Point> &blocks) {
...
while (!ok && attempts < 1000) {
f.x = rand() % (w - 4) + 2;
f.y = rand() % (h - 4) + 2;
if (!inside(f)) { attempts++; continue; }
if (occupied(f)) { attempts++; continue; }
if (freeNeighborCount(f) < 2) { attempts++; continue; }
ok = true;
}
Board size, snake and obstacles all arrive as parameters. What does not — and what does that cost?
Answer
rand(). Everything the function needs was handed to it except the one thing
that decides the answer.
Partial injection is not a beginner's mistake. It is what happens when you pass in the things you were already passing around, and stop at the one that feels like part of the language.
There is a real rule on that third condition: food never spawns anywhere with
fewer than two free neighbours, so it never appears in a dead end. That is a
deliberate design decision, and no test can check it — not because it is hard, but
because nobody can choose what rand() returns.
One parameter fixes it:
Point generateFood(int w, int h, const vector<Point> &snake,
const vector<Point> &blocks, function<int()> next_random);
Then a test scripts the sequence, aims the first draw straight at a dead end, and asserts the function rejected it.
The object seam, at full size
confirm_order took a mailer and the slide fit in four lines. Here is the same move on a
real domain class — and this time the seam is not rescuing a call, it is the thing that
separates the rule from the infrastructure.
class InvoiceRepository(Protocol):
def all(self) -> list[Invoice]: ...
class InvoiceFilter:
def __init__(self, repo: InvoiceRepository): # the seam
self._repo = repo
def low_value_invoices(self):
result = []
for inv in self._repo.all():
if inv.value < 100:
result.append(inv)
return result
The rule did not change. self._all() became self._repo.all(). Everything that made this class
untestable lived in the four lines that fetched the data, never in the rule itself.
The test you could not write before
repo = InMemoryInvoices([("A", 50), ("B", 120), ("C", 90)])
invoice_filter = InvoiceFilter(repo)
assert invoice_filter.low_value_invoices() == [("A", 50), ("C", 90)]
No connection string, no schema, no rows to seed, no cleanup. InMemoryInvoices is a
fake in the Lecture 11-12 sense — a working implementation that takes a shortcut
unfit for production.
The class was never hard to test. It was hard to reach.
A bigger one: four collaborators
Suppose an online web shop has the following requirements. For all the shopping carts that were paid today, the system should
- Set the status of the shopping cart as ready for delivery, and persist its new state in the database.
- Notify the delivery center, and let them know they should send the goods to the customer.
- Notify the SAP system.
- Send an e-mail to the customer confirming that the payment was successful. The e-mail should contain an estimate of when delivery will happen. The information is available via the delivery center API.
class ShoppingCartRepository(Protocol):
def carts_paid_today(self) -> list[Cart]: ...
def persist(self, cart) -> None: ...
class DeliveryCenter(Protocol):
def deliver(self, cart) -> date: ...
class CustomerNotifier(Protocol):
def send_estimated_delivery_notification(self, cart) -> None: ...
class SAP(Protocol):
def cart_ready_for_delivery(self, cart) -> None: ...
class PaidShoppingCartsBatch:
def __init__(self, db, delivery_center, notifier, sap): # four seams
self._db, self._delivery_center = db, delivery_center
self._notifier, self._sap = notifier, sap
def process_all(self):
for cart in self._db.carts_paid_today():
estimated_day = self._delivery_center.deliver(cart)
cart.mark_as_ready_for_delivery(estimated_day)
self._db.persist(cart)
self._notifier.send_estimated_delivery_notification(cart)
self._sap.cart_ready_for_delivery(cart)
Four dependencies, four interfaces, one constructor. process_all performs no I/O of its
own — it only decides the order in which four collaborators are called. That ordering is
the entire domain rule, and a test can now watch it happen with four fakes.

What you use a seam for
Two jobs, and they need different substitutes.
| Goal | What the substitute must do | |
|---|---|---|
| Separation | stop unwanted behaviour running | nothing. An empty function is enough |
| Sensing | see what the code did, when the effect is otherwise invisible | record the calls and their arguments |
A stub separates. A spy senses. The seam is what lets either one be installed.
Start sensing with the simplest recording you can, and let it grow only as far as the assertions force it.
💬 Three tests. What does each one tell you about the code it tests?
No commentary, just the opening lines. Read them as evidence.
# A
@patch("billing.stripe.Charge.create")
def test_refund_is_capped(mock_create):
// B
#define main snake_main
#include "../test.cpp"
# C
svc = OrderService(mailer=FakeMailer())
Answer
| What the test had to do | What the design offered | |
|---|---|---|
| A | reach past the code and patch a name inside a third-party module | no seam of its own — it borrowed Python's |
| B | rename the entry point and swallow the whole file | nothing, at any of the three stages |
| C | pass an argument | a seam, deliberately |
A is the interesting one. It works, it is common, and it is still a report: the
code under test named stripe directly, so the test had to know that too. Rename
the dependency and the test breaks without a single behaviour changing.
B is what happens when the answer is none of the three — covered on the next slide.
A test is a receipt for what the design would not give it.
Read your own tests this way and you never need to ask whether a design is testable. The tests already said.
💬 Which seam did your snake game leave you?
Open your own repository. Find where you read the clock, the keyboard, or the random number generator. Ask what you would have to change to substitute it — and where that change would live.
Answer
For almost all of you the honest answer is none of the three.
A link seam needs the dependency to be behind a function you link against. A
preprocessing seam needs the call to come through a header you can shadow. An
object seam needs something to pass in. A direct getch() in the middle of a
loop, in a single translation unit, offers no substitution point at any of the
three stages.
That is why the harness for this course had to rename main and swallow the
whole file. When a design offers no seam, the test does not get to stop
needing one — it just has to buy a worse one.
2. Dependency injection and Controllability
- Controllability
- We should be able to control what a class under test does?
- Dependency Injection
- All external dependencies should be passed into a class (e.g. via its constructor or setter methods) rather than hard-coded inside it.
- By coding to an abstraction rather than a concrete class, unit tests can “plug in” a stub or a fake. What supplies the abstraction is a language detail — a
Protocol, an abstract base class, an interface, or a plain callable passed in.
In the snake game
getch() appears in 32 of your 36 repos, called from inside the loop it drives:
class Game:
def step(self):
key = read_key() # the real keyboard. nothing can control it
...
Pass it in instead:
class Game:
def __init__(self, read_key):
self._read_key = read_key
def step(self):
key = self._read_key()
keys = iter(["a", "a", "w"]) # a scripted player
g = Game(read_key=lambda: next(keys))
g.step(); g.step(); g.step() # left, left, up
That is controllability: the test decides what the player does. Without the seam, the only way to exercise a left turn is for a human to press a key.
3. Make classes and methods observable
Observability, at the class level, is about how easy it is to assert that the behavior of the functionality went as expected.
Ensure that your classes provide developers with simple and easy ways to assert their state.
Common way:
- Introducing methods to facilitate assertions
- void methods are hard to test and can be improved by making them return some assertable value instead
Example:
| Aspect | Not-so-good design | Good design |
|---|---|---|
| Diagram | ![]() |
![]() |
| Testability | - The only way to verify behavior is to spy on scheduleDelivery().- If someone refactors the internals (e.g., calls a helper method), test breaks even though externally behavior is same → brittle. - No direct observable state. |
- Test checks behavior through observable program state, not implementation details. - No mocking or spying needed for verification. - Easier to read, maintain, and refactor safely. |
In the snake game
grow() returns nothing, so a test can only spy on it or reach into a private field:
class Snake:
def grow(self): # void. did anything happen?
self._growing = True
Add the query the game loop wants anyway:
class Snake:
def grow(self): ...
def length(self): return len(self._body)
s = Snake(body=[(5, 5), (5, 6)])
s.grow()
s.step(UP)
assert s.length() == 3
One method to add, and the test stops caring how growth is implemented.
💬 Make one snake method observable
The table above says: verify through state, not by spying. In most of your repos a
collision is detected inside logic() and acted on immediately — a global flag is
set, or game_over() is called, or something is printed.
What would you add so a test can assert a collision without spying on anything?
Answer
A query. The collision already happens; nothing exposes the result.
class Snake:
def step(self, direction): ... # command — moves, may die
def is_alive(self): ... # query — the addition
s = Snake(body=[(0, 5)], board=(20, 20))
s.step(LEFT) # walks into the wall
assert s.is_alive() is False
No spy, no screen scraping, no global to reset between tests. This is the command/query split from Lecture 11-12, used as a design tool rather than a classification.
One caution: this is not a licence to add a getter per private field.
is_alive()is observable behaviour — the game loop already needs it to know when to stop. A getter that only a test would ever call is Fix 1 wearing a different hat.
4. Dependency via class constructor or value via method parameter
- Receiving a dependency via constructor adds a little complexity to the overall class and its tests but simplifies its client classes.
- Receiving the data via method parameter simplifies the class and its tests but adds a little complexity to the clients.
class Game:
def __init__(self, place_fruit, board): # dependency — fixed for this Game
self._place_fruit = place_fruit
self._board = board
def step(self, direction): # data — different every tick
...
g = Game(place_fruit=lambda: (5, 5), board=(20, 20)) # dependencies supplied once
g.step(UP) # only changing data per call
g.step(LEFT)
The same dependencies can instead be passed through the method:
class Game:
def step(self, direction, place_fruit, board):
# The class is simpler: it does not store or initialize dependencies.
...
# Every client must now know, retain, and repeatedly supply the dependencies.
place_fruit = lambda: (5, 5)
board = (20, 20)
g = Game()
g.step(UP, place_fruit, board)
g.step(LEFT, place_fruit, board)
This makes Game and its unit tests easier to set up because each call is self-contained.
However, it makes clients more complex: every call site must provide the correct dependencies,
even when they remain fixed throughout the object's lifetime. Adding another dependency would
also require changing every client call.
If it varies per call, pass it per call. If it is fixed for the object's life, pass it once. The fruit placer and board are dependencies; the direction is data.
Testability in a real code base
The rule that cannot run without a database
get_day_substitution answers a domain
question — which weekday does campus actually run on this date? It opens its own
connection to do it:
def get_day_substitution(date_str):
query = "SELECT event_name FROM academic_calendar WHERE ..."
with db_connection() as conn: # infrastructure, inside the rule
with conn.cursor() as cur:
cur.execute(query, (date_str, date_str))
return _parse_day_substitution([r[0] for r in cur.fetchall()])
Same shape as InvoiceFilter._all(). Rows 3 and 5 of the pains table.
Lecture 11-12 showed the workaround: the mock_db
fixture reaches in from outside and patches db_connection for the duration of a test.
That is a seam cut by the test, not offered by the design. Monkeypatching is what you reach for when technique §1 was never applied.
Three flaws in nine lines
is_gemini_available decides whether to call the
model or fall back to local NLP:
_gemini_healthy = True # module-level mutable state
_gemini_last_check = 0.0
def is_gemini_available() -> bool:
global _gemini_healthy, _gemini_last_check
if not _gemini_healthy:
if time.time() - _gemini_last_check < _GEMINI_COOLDOWN: # reads the clock
return False
_gemini_healthy = True # a query that writes
return True
| What it does | Pains row | Cost in a test |
|---|---|---|
| module-level mutable state | 2 — state leaks | one test that records a failure changes the next test's answer |
calls time.time() directly |
5 — hidden input | you cannot test the cooldown without sleeping 60 seconds |
| writes the flag it reports on | — | asking twice gives two different answers |
The corresponding test exposes those costs:
def test_gemini_recovers_after_cooldown(monkeypatch):
# PAIN: the test must reach into module globals to arrange the initial state.
gemini._gemini_healthy = False
gemini._gemini_last_check = 1000.0
# PAIN: without patching the hidden clock, this test must really sleep for 60 s.
# PAIN: patching requires knowing where `time` was imported in the implementation.
monkeypatch.setattr(gemini.time, "time", lambda: 1061.0)
assert gemini.is_gemini_available() is True
# PAIN: a method named like a query changes shared state as a side effect.
assert gemini._gemini_healthy is True
# PAIN: cleanup is essential; otherwise this test can affect whichever test runs next.
gemini._gemini_healthy = True
gemini._gemini_last_check = 0.0
Technique §2 fixes all three at once — hold the state in an object, inject the clock:
class GeminiCircuit:
def __init__(self, clock, cooldown=60.0): # clock is now a seam
self._clock, self._cooldown = clock, cooldown
self._healthy, self._last_check = True, 0.0
def is_available(self): ...
def record_failure(self): ...
Its test controls time through the public dependency and owns all state locally:
def test_gemini_circuit_recovers_after_cooldown():
now = [1000.0]
circuit = GeminiCircuit(clock=lambda: now[0], cooldown=60.0)
circuit.record_failure()
assert circuit.is_available() is False
# No sleep or implementation-level patch: advance the injected fake clock.
now[0] = 1061.0
assert circuit.is_available() is True
# No cleanup: this state belongs only to this test's circuit instance.
The second test is fast, deterministic, and independent of test order. It also interacts only
through GeminiCircuit's public API rather than modifying production-module internals.
36 snake games
Lecture 11-12 counted the untestable dependencies across your repos: getch in 31,
Sleep in 28, rand in 22, the screen in 18, a high-score file in 17.
The reason none of them can be stubbed is not the language. It is where the call sits:
def logic(): # 22 of 36 repos, shape varies
...
if head == fruit:
score += 10
fruit = (rand() % width, rand() % height) # no seam
Technique §4 — pass the value in through the method — costs one parameter:
def logic(place_fruit):
...
if head == fruit:
score += 10
fruit = place_fruit()
logic(place_fruit=lambda: (5, 5)) # the fruit lands where the test says
Nothing about the game changed. The rule became reachable.
Back to the claim
Untestable code is a design smell.
Every pain in this lecture had a design flaw underneath it, and each flaw already had a name before unit testing existed: coupling, global state, mixed concerns, Demeter, DIP, SRP, OCP. None of them were invented to make testing easier. A test is simply the cheapest place to notice them, because it is the first client of your code whose requirements you control completely.
Making code testable is not the same as improving it.
Fix 1 is always available. Make the private method public, expose the field, add a setter nobody needs, hand the test a back door. The suite goes green and the design is worse than when you started.
The four techniques are the whole difference: each one makes the code testable by removing the coupling rather than routing around it.
If a change made your code easier to test and harder to explain, you did Fix 1.
References:
- Michael Feathers - the deep synergy between testability and good design - YouTube
- Chapter 7, Designing for Testability "Effective Software testing by MAURÍCIO ANICHE"
- Chapter 4, The Seam Model — Working Effectively with Legacy Code, Michael Feathers

