Introduction
Most articles about testing focus on tools, frameworks, and syntax.
This one is about something more important.
In this article I walk through my approach to testing and the thinking process behind each step. Not just what kind of tests to write, but why writing tests fundamentally changes the way you think and grow as a programmer.
Good tests do more than validate logic. They train you to reason clearly about behavior, design better interfaces, catch flawed assumptions early, and build systems that remain understandable as they grow.
If you stick with this article, you will not only be more motivated to write good tests, you will understand why testing is one of the most effective ways to become a better engineer.
Sounds interesting?
Let’s dive in.
What to test
Let’s start with an uncomfortable truth.
Most of us write tests as an afterthought; To satisfy a requirement, to make CI green, or to avoid someone else complaining about coverage dropping by a few percent.
That mindset completely misses the point.
In principle, all public-facing code should be tested:
functions, classes, modules, and interfaces.
Tests should be first-class citizens in your codebase. They should live next to the code they verify, evolve with it, and run automatically as part of your development process.
The idea that “I don’t have time to write tests” is a fallacy. Writing tests saves time by catching bugs early, preventing regressions, and making refactoring safe. More importantly, it forces you to think critically about behavior, and that leads to better code.
You might not agree with me yet.
By the end of this article, I hope you will.
Considering testability while writing software
Once you know what to test, the next question becomes unavoidable:
How testable is the code I am writing right now?
And this is where the real transformation begins.
If writing your tests feels painful, complex, or unnatural, that is almost always a design problem — not a testing problem. In my honest and slightly brutal opinion, code that cannot be tested without suffering is usually bad code.
When that happens, the only correct response is to stop, rethink the design, and restructure. Anything else becomes technical debt, the kind that quietly compounds until the eventual rewrite becomes inevitable.
Designing for testability forces you to:
- separate concerns,
- define clear responsibilities,
- and build clean, stable interfaces.
Use principles like dependency injection, modular design, and loose coupling. Not because a textbook says so, but because your tests demand it.
Testing can be hard. But the simple act of having to consider testability while you write code already improves your design dramatically.
Before you ever run a single test.
Unit testing
Having considered testability from the start, you have already done something important: you have forced yourself to think about your code as a system of behaviors, not just lines of logic. Unit testing is where that mindset truly starts paying off.
This is the point where testing stops being about “checking if the code works” and starts becoming a tool for understanding what the code is supposed to be.
The goal of unit tests
Unit tests validate individual components or functions in isolation to ensure they behave correctly. They are the first line of defense against bugs, and the first tests you should implement before integrating new functionality into existing code.
But that is only the surface benefit.
The real value of unit tests is what they force you to do before the test ever passes.
More often than not, by the time I have written my unit tests, more than 95% of bugs have been fixed and I am very confident in the fact that the implemented behavior in question works as expected. Writing unit tests requires you to think critically about the internal logic and usage of your functions.
Many people skip unit tests because they change often and feel tedious to maintain. Ironically, that is exactly why they are so powerful.
Having unit tests in place means that when you make a change, you are forced to reason about its consequences:
- Is the function’s behavior still correct?
- Is the interface still valid?
- What other parts of the code rely on this behavior?
- What assumptions did I just break?
That process of forced reasoning has a much larger impact on code quality than most people realize. This is not a side effect of unit tests. This is their fundamental purpose.
And this is only part of what unit tests give you.
If unit tests are going to make you a better programmer, you must understand the questions they are training you to ask.
Here are the questions I constantly ask myself while writing them.
They do not just help me write tests, they improve the code itself:
What are the valid inputs for this function?
- What are the expected outputs for these inputs?
- Have I documented those expectations?
- Does the implementation actually satisfy them?
What are the invalid inputs?
- What should happen in those cases?
- Should the function raise an exception?
- Is that behavior clearly documented?
Do I need to re-examine logic while debugging tests to understand what the code does?
Any part of the code that is not immediately clear from reading the logic should be commented.
If you practice asking these questions consistently, something interesting happens: you start writing better code before you even write the test.
That is the real training effect of unit testing.
How to write unit tests
In all of my code examples I will use Python, because it is generally easy to understand even if it is not your primary language. The goal here is not Python, it is the thinking process.
Let’s say I implement a function that adds two numbers together:
def add(a, b):
"""
Adds two integers.
:returns int: The sum of the two numbers.
:raises TypeError: If any of the inputs are not integers.
"""
return a + b
Simple enough, looks good, right?
Now let’s write a unit test for the success case:
import pytest
@pytest.mark.parametrize(("a", "b", "expected"), [
(1, 2, 3),
(-1, 1, 0),
(0, 0, 0),
])
def test_add(a, b, expected):
assert add(a, b) == expected
At this point, many people stop and move on.
But if you apply the mindset from the questions above, something feels off.
Without having written a failure case, are we really confident that this function behaves as its documentation claims?
Take a moment and try to find the edge case before reading on.
If you get stuck, go back to the questions.
Now let’s write the failure tests:
@pytest.mark.parametrize("a", [1.5, "1", None])
@pytest.mark.parametrize("b", [None, "2", 5.5])
def test_add_raises_type_error(a, b):
with pytest.raises(TypeError):
add(a, b)
Now the problem becomes obvious.
One combination of inputs fails this test:
a = 1.5 and b = 5.5.
According to the function’s documentation, the function should raise a TypeError if any input is not an integer, but it doesn’t.
This is a trivial example, but it demonstrates something extremely important: Your test did not just validate behavior. It exposed a mismatch between intent, documentation, and implementation.
That is the real work of unit testing.
To fix this, you must now make a design decision:
- enforce integer-only input,
- allow floats,
- or rename the function to reflect reality.
Trying to catch this kind of problem later at the integration level is far harder, because at that point your attention is no longer on the function’s contract, it is on the system as a whole.
Unit testing keeps your thinking sharp, local, and precise.
Integration testing
Once you have ensured that individual components work correctly through unit testing, the next step is to verify that those components work together through integration testing.
This is often where things begin to feel messy, because integration tests expose design problems that were never confronted during implementation.
Unit tests train you to reason about correctness.
Integration tests train you to reason about design.
The goal of integration testing
Integration tests ensure that different modules or services work together as expected and catch issues that arise from their interaction.
But their most important contribution is something else entirely: they force you to confront your code at an abstract level.
Integration testing is the first point where you are no longer thinking in terms of individual functions. You are thinking in terms of:
- responsibilities,
- boundaries,
- contracts,
- and how your system presents itself to the outside world.
This is an entirely different mode of thinking, and it is one of the core skills of strong software engineers.
Once I've started with integration testing, I may realize that I got some things wrong on a more abstract level than what the unit tests cover. I might need to restructure what I implemented to make it more coherent or easier to use. I might even realize that I don't need a specific function at all or that the idea behind my implementation was flawed.
This is a good thing. It allows me to catch design problems early, before they solidify into spaghetti code or bad architecture that becomes increasingly difficult to fix once deeply integrated into the system.
After you have written enough integration tests, something interesting happens: you naturally begin to question your designs before you implement them.
At that point, testing has already changed how you think.
Here are the kinds of questions integration testing trains you to ask — questions that dramatically improve both your tests and your code:
Am I missing any functionality?
Can the end user actually do everything this system is supposed to support?Does this function need to be exposed to the end user?
Should this be public or private? Does it even belong here?Am I following best practices?
Are there obvious opportunities for refactoring or cleaner design?Do I need to look up how to use my own functions while writing tests?
If yes, the interface is wrong.
If I wrote the function, I should know how to use it without reading the documentation.Will I need to read my code later to understand what it does?
If yes, then the function signature, argument names, or documentation are not clear enough.
I wrote the function — I am responsible for its clarity.Are the inputs practical for the intended use case?
Are the outputs useful to the end user?
Are the exceptions raised by my functions easy to work with?
If you practice asking these questions consistently, you stop writing code that merely works and start building systems that are usable, understandable, and resilient.
What does an integration test look like?
Here is a simple example of an integration test.
In this case, we are testing a component that interacts with a real database.
def test_integration():
real_database = RealDatabase()
real_database.insert_user(User(id=1, name='Alice'))
component_to_test = Component(database=real_database)
user = component_to_test.interact_with_database(user_id=1)
assert user.id == 1
assert user.name == 'Alice'
Note that an external system such as a database is not required for a test to qualify as an integration test.
More often than not, your code forms a hierarchy: the top level is the user-facing interface, and the bottom level is the smallest unit of logic.
An integration test verifies the interaction between:
- two or more components at the same level,
- two or more levels of this hierarchy,
- or both.
This can be:
- an interface calling into lower-level components,
- components coordinating with each other,
- or a function interacting with the file system, database, or network.
You may mock certain layers while testing others, what matters is that you are testing real interactions between meaningful parts of the system.
This is illustrated in the diagrams below.
What does an integration test not look like?
If you mock out the entire interaction with all other components, you are not writing an integration test. You are writing a unit test.
This is a common misconception.
An integration test only exists when you are validating the behavior between components, not a single component in isolation.
def test_is_not_integration():
mock_database = MockDatabase()
mock_database.interaction.return_value = User(id=1, name='Alice')
component_to_test = Component()
component_to_test.database = mock_database
user = component_to_test.interact_with_database(user_id=1)
assert user.id == 1
assert user.name == 'Alice'
This test is valuable, but it belongs firmly in the unit testing category.
Hardware testing
Up until now, most of the discussion has lived inside the relatively safe world of software. Hardware testing is where that safety disappears, and where some of the most important lessons of engineering begin.
When your code touches real devices, real sensors, real timing constraints, and real physical limits, vague thinking becomes impossible. Every assumption you made is either validated by reality or brutally exposed.
This is why, even if you never work with hardware professionally, the thinking patterns that hardware testing forces on you are some of the most valuable you can develop as a programmer.
Goal of hardware testing
Hardware tests are a specialized form of integration testing that validate how software interacts with physical components, ensuring correctness, performance, and reliability under real-world conditions.
A common example is HIL (Hardware-in-the-Loop) testing, where software is exercised against real hardware through a test bench or controlled environment.
During development, these systems are often mocked using state machines or simulations that approximate real behavior. Hardware testing closes the gap between those simulations and reality.
What you are truly testing here is not just the interaction with hardware, you are testing your assumptions about reality.
If you validate that your software mocks behave the same way as real hardware at the boundary where they meet, then every test above that layer becomes dramatically more trustworthy.
That confidence is the result of disciplined thinking, not luck.
Mocking hardware (accounting for testability)
Consider a system that controls a motor. Before testing even enters the picture, the design might look something like this:
To enable development and testing without physical hardware, we introduce a state machine that simulates motor behavior.
Take a moment to study the state machine:
In simple terms, the motor:
- can be turned on and off from any state
- enters
Drivingmode when turned on and speed is greater than zero - cannot exceed a speed of 50
- cannot drive backwards
At this point, something important happens to your thinking as an engineer:
You are no longer just writing code — you are designing a model of reality.
To preserve that model throughout the system, the code must be structured so the mock and the real motor controller are interchangeable. This is where interfaces and dependency injection stop being abstract principles and become practical survival tools.
The design must evolve into this:
Now the same system can operate on real hardware or on simulation, without rewriting everything.
We start by defining the abstract interface:
from abc import ABC, abstractmethod
class MotorController(ABC):
"""
A controller to interact with a motor.
"""
@abstractmethod
def set_speed(self, speed: int):
raise NotImplementedError
@abstractmethod
def get_speed(self) -> int:
raise NotImplementedError
@abstractmethod
def turn_on(self) -> None:
raise NotImplementedError
@abstractmethod
def turn_off(self) -> None:
raise NotImplementedError
@abstractmethod
def is_on(self) -> bool:
raise NotImplementedError
@abstractmethod
def is_driving(self) -> bool:
raise NotImplementedError
@abstractmethod
def is_off(self) -> bool:
raise NotImplementedError
Then we implement the mocked controller backed by the state machine:
class MockMotorController(MotorController):
"""
A mocked implementation of a motor controller.
"""
def __init__(self):
self.__state_machine = MotorStateMachine()
def set_speed(self, speed: int):
self.__state_machine.set_speed(speed)
def get_speed(self) -> int:
return self.__state_machine.speed
def turn_on(self) -> None:
self.__state_machine.on()
def turn_off(self) -> None:
self.__state_machine.off()
def is_on(self) -> bool:
return self.__state_machine.state == 'On'
def is_driving(self) -> bool:
return self.__state_machine.state == 'Driving'
def is_off(self) -> bool:
return self.__state_machine.state == 'Off'
And finally the real interface used throughout the system:
class Motor:
"""
Interface to interact with a motor.
"""
def __init__(self, motor_controller: MotorController):
self.__controller = motor_controller
def set_speed(self, speed: int):
self.__controller.set_speed(speed)
def get_speed(self) -> int:
return self.__controller.get_speed()
def turn_on(self) -> None:
self.__controller.turn_on()
def turn_off(self) -> None:
self.__controller.turn_off()
@property
def on(self) -> bool:
return self.__controller.is_on()
@property
def driving(self) -> bool:
return self.__controller.is_driving()
@property
def off(self) -> bool:
return self.__controller.is_off()
This is what testability looks like in practice: designing systems where reality can be swapped in without rewriting everything.
The importance of validating mock assumptions
Once your mock exists, the next step is critical: you must verify that the model of reality you created is actually correct.
At minimum, you should validate assumptions such as:
- The motor can be turned off from the
Drivingstate - Speed is capped at 50
- Speed cannot go below 0
- Turning the motor on twice does not alter state
Validating these against real hardware gives you enormous confidence in the rest of your system. Above that layer, your software becomes predictable and testable.
This is not about hardware anymore.
This is about learning to build trustworthy systems.
How to write hardware tests
Hardware tests are simply integration tests that cross the boundary between software and reality.
def test_motor():
motor = Motor(motor_controller=RealMotorController())
motor.turn_on()
assert motor.on
motor.set_speed(45)
assert motor.speed == 45
motor.turn_off()
assert motor.off
assert motor.speed == 0
Finding and testing functional requirements
At this level, testing expands beyond correctness and into engineering responsibility.
Functional requirements define what the product must do in the real world.
For example:
“The motor must run at top speed for at least 5 minutes without exhausting the battery.”
That requirement becomes a test:
def test_motor_can_drive_for_five_minutes_at_top_speed():
motor = Motor(motor_controller=RealMotorController())
motor.turn_on()
motor.set_speed(50)
start_time = time.time()
while time.time() - start_time < 5 * 60:
assert motor.speed == 50
time.sleep(1)
motor.turn_off()
assert motor.off
With a few lines of code, you have transformed a vague business requirement into a verifiable engineering contract.
That transition, from idea to enforceable reality is one of the most important skills a programmer can develop.
System testing
Having tested individual units, their integration, and hardware interactions, system testing is where everything finally comes together.
This is the stage where you stop thinking like someone merely writing code and start thinking like someone delivering a real product, and over time, like a system architect responsible for the behavior of the whole.
The goal of system testing
System tests validate the behavior of the entire system against its requirements in an environment that closely mirrors production.
If the previous testing stages have been done correctly, system tests should feel almost easy.
That is not because the system is simple, it is because you have already forced yourself to solve the hard problems earlier.
By the time you reach system testing, you are no longer hunting bugs. You are verifying that the system, as a whole, fulfills its purpose.
This is what mature engineering looks like.
How to write system tests
Because your code has been structured to allow components to be swapped without rewriting everything, system testing becomes a powerful and flexible tool.
You can execute the same system tests against:
- real hardware,
- partial mocks,
- or fully simulated environments,
simply by changing configuration.
This is not an accident. It is the direct result of the design decisions you were forced to make earlier by unit testing, integration testing, and hardware testing.
Here is a real-world example:
import os
import time
import pytest
@pytest.mark.parametrize(
'motor_controller', [
pytest.param(
MockMotorController(),
marks=pytest.mark.skipif(
os.environ.get("MOTOR_CONTROLLER_TYPE", "") != "MOCK",
reason="Motor controller is not set to MOCK",
),
),
pytest.param(
T16SRMotorController(),
marks=pytest.mark.skipif(
os.environ.get("MOTOR_CONTROLLER_TYPE", "") != "T16SR",
reason="Motor controller is not set to T16SR",
),
),
pytest.param(
T19LRMotorController(),
marks=pytest.mark.skipif(
os.environ.get("MOTOR_CONTROLLER_TYPE", "") != "T19LR",
reason="Motor controller is not set to T19LR",
),
),
]
)
@pytest.mark.parametrize(
'steering_controller', [
pytest.param(
MockSteeringController(),
marks=pytest.mark.skipif(
os.environ.get("STEERING_CONTROLLER_TYPE", "") != "MOCK",
reason="Steering controller is not set to MOCK",
),
),
pytest.param(
AK310SteeringController(),
marks=pytest.mark.skipif(
os.environ.get("STEERING_CONTROLLER_TYPE", "") != "AK310",
reason="Steering controller is not set to AK310",
),
),
pytest.param(
AK420MotorController(),
marks=pytest.mark.skipif(
os.environ.get("STEERING_CONTROLLER_TYPE", "") != "AK420",
reason="Steering controller is not set to AK420",
),
),
])
def test_system(motor_controller, steering_controller):
"""
Tests the system with any combination of motor and steering
controller that is not skipped by the parametrization conditions
"""
motor = Motor(motor_controller=motor_controller)
steering = Steering(steering_controller=steering_controller)
system = System(motor=motor, steering=steering)
assert system.coordinates == (0, 0)
system.drive_autopilot(
target_coordinates=(100, -300),
timeout_seconds=60
)
while not system.arrived() and not system.timeout():
time.sleep(1.0)
assert system.coordinates == (100, -300)
This test does not care how the system is built internally.
It only cares about one thing:
Does the system accomplish its real-world goal?
And that is exactly what you, as an engineer, are responsible for.
Where the training becomes visible
If you look back across all stages — unit tests, integration tests, hardware tests, and now system tests — a pattern emerges.
Testing has continuously forced you to:
- clarify your thinking,
- expose your assumptions,
- design clean interfaces,
- reason about failure,
- and express behavior precisely.
Over time, those habits become automatic.
You stop writing fragile code.
You stop building opaque systems.
You stop shipping uncertainty.
And that is why the greatest benefit of testing is not the tests.
It is the engineer you become by writing them.
Conclusion
If this article did its job, you no longer see testing as something you “should probably do.”
You see it as a discipline that actively trains you to think better.
Across unit tests, integration tests, hardware tests, and system tests, the same pattern keeps appearing: testing forces you to clarify your thinking, confront your assumptions, and design with intention. Over time, those habits stop being something you do, they become part of how you approach every problem.
That is why testing is not just about preventing bugs or satisfying a CI pipeline. It is one of the most effective ways to grow from someone who writes code into someone who builds real systems.
By integrating testing into your daily development process, you do more than catch errors early. You build software that is easier to understand, easier to change, and far more reliable under real-world conditions. And perhaps more importantly, you become the kind of engineer who can reason clearly about complex systems.
That is the real payoff of writing tests.
Happy testing.





Top comments (0)