2. Unit Testing
Unit tests, or Unit testing, are a methodology to check whether your code works as you expect. Trying to reach the limits in order to guarantee the quality of the work. They also give you peace of mind when adding new features, because they warn you in case some earlier code stops working.
Although at first glance it feels like we're wasting time, it actually economizes every line of code and makes the project more profitable. How is that possible? As you build the different tests, possibilities of error that nobody had foreseen come to light, which leads to finding fewer errors in the future. Ergo, you'll spend fewer hours fixing problems. At the start of the project productivity is low, true, but look at the chart and see what happens when the project grows.
:quality(85)/https://andros.dev/static/img/courses/testing/tdd.jpg)
Anatomy of a test
Let's start with the most basic thing: how to write a test.
You create a separate file, usually with the test_ prefix or the _test.py suffix. Inside you define functions that also follow that convention (they start with test_). Each function contains one or more assertions that verify the expected behavior.
An assertion is a check: it is followed by an expression that must be true. If it is, the test continues. If not, the tool stops and shows you what you expected and what you got.
For example, inside a file called test_sum.py:
def test_sum():
assert sum([1, 2, 3]) == 6
{: .advice } The example code (variable names, functions and comments) is in English, as is done in the industry. The explanations, in English too.
To run the test we install the tool once:
pip install pytest
And we launch:
pytest
pytest walks through the folder, finds the files and functions that follow the convention, runs them and tells you what passes and what fails. There's no need to register anything anywhere.
The structure of a test: Given-When-Then
It is often said that testing is a craft, that each case is unique. It's not true. We have patterns that help us get started and structure our work. The simplest and best known is Given-When-Then.
It was born with BDD (Behavior-Driven Development) at the hands of Daniel Terhorst-North and Chris Matts. It proposes dividing the test into 3 informal blocks of comments:
- Given: you prepare the scenario, the input data, the conditions.
- When: you run the code you want to test.
- Then: you verify the final result.
It is also known as Arrange, Act, Assert. It's the same idea. Always divide your tests this way and they will read themselves.
Before writing code, describe the test in prose. We're going to test the story of "the 3 little pigs". The goal is to check that they are safe from the wolf.
Given 3 houses, ['straw', 'wood', 'bricks']...
When the wolf blows on each one...
Then 1 or more houses must be left standing.
With that structure you can already write the test in any language. Let's see it with a function that decides whether a coordinate is in the tropics:
def test_is_tropic():
# Given
latitude = 0
longitude = 0
# When
result = is_tropic(latitude, longitude)
# Then
assert result is True
def test_is_not_tropic():
# Given
latitude = 45
longitude = 45
# When
result = is_tropic(latitude, longitude)
# Then
assert result is False
Names that describe the case
The name of a test is the description of what it verifies. test_is_not_tropic tells you instantly what broke when it turns red. test_1 tells you nothing. Invest half a second in the name and you'll save minutes of debugging.
Parametrization: many cases with a single test
Imagine I need to test a large number of results on the same function, is_full(), which tells me whether a battery is full:
| Function | Percentage | Expected |
|---|---|---|
| is_full() | 0 | False |
| is_full() | 10 | False |
| is_full() | 30 | False |
| is_full() | 60 | False |
| is_full() | 99 | False |
| is_full() | 100 | True |
I'd have to write six nearly identical tests. You'll think you have better things to do, and you're not wrong. Luckily you weren't the only one: almost all frameworks let you parametrize, that is, run the same test with different sets of data.
import pytest
@pytest.mark.parametrize(
"percentage, expected",
[
pytest.param(0, False, id="empty"),
pytest.param(10, False, id="low"),
pytest.param(30, False, id="medium_low"),
pytest.param(60, False, id="medium_high"),
pytest.param(99, False, id="almost_full"),
pytest.param(100, True, id="full"),
],
)
def test_is_full(percentage, expected):
assert is_full(percentage) == expected
Each pytest.param is a case, and the id gives it a name so that, if one fails, you know which one at a glance. A single test, six scenarios, zero duplication.
Fixtures: reusing test objects
Sometimes, to test a function, you need to prepare an object or some data beforehand. If you repeat that preparation in every test, you violate the DRY principle (Don't Repeat Yourself) and maintenance skyrockets.
A fixture is a reusable piece of preparation. You define it once and request it as an argument in the tests that need it:
import pytest
@pytest.fixture
def battery():
return Battery(percentage=50)
def test_battery_is_not_full(battery):
# Given: we already have the battery ready, no need to create it
# When
result = battery.is_full()
# Then
assert result is False
It also serves to read test files (CSV, JSON), configuration constants or shared data. The idea is always the same: prepare once, reuse many times.
What about external dependencies?
Testing your code is easy when it only depends on itself. But what happens when there's a database, an API or the system clock in the middle?
{: .advice } Never, ever, ever test against the production database. It doesn't matter the reason: time, saving code, simplifying... It always ends in disaster. How do you explain to your boss that you deleted all the store's users because a test went wrong?
That problem has a solution, and it's important enough to devote a whole lesson to it. We'll see it in detail in Test doubles.
The cases we almost always forget
When you write tests a pattern appears. These are the cases that are worth always keeping in mind:
- The data is correct and the expected result is correct. The ideal case.
- The data is incorrect and the expected result is a controlled error.
- The inputs are insufficient. For example, a required field is missing.
- The inputs have incorrect types. For example, a number instead of a list.
- All possible error messages are returned.
- Extreme input data. For example, an email of 1000 characters.
- Boundary input data. For example, an email of 254 characters, which is the maximum valid length.
Activity 1
We're going to create a simple LudoGame object to manage the players of a game of Ludo.
1. Add player (name and color).
2. Remove player.
3. Start game.
Now the testing.
1. There can be at most 4 players.
2. The colors are fixed: red, yellow, blue and green.
3. Players can only be removed by searching by name.
4. Names cannot be repeated.
5. The game can only be started with a minimum of 2 players.
6. Any other functionality you deem appropriate.
Activity 2
You are tasked with a Concert object with the intention of managing the tickets.
1. Buy (name and ID number).
2. See available and sold tickets.
3. Money earned (each ticket costs 58 euros).
Testing:
1. There are at most 100 tickets available.
2. ID numbers cannot be repeated.
3. The number of available and sold tickets must be consistent (20 free, ergo 80 bought).
4. When buying a ticket no fields can be left empty.
5. Any other functionality you deem appropriate.
Activity 3
Write a function that receives a list of temperatures and returns the average.
1. With a normal list, it returns the average.
2. With an empty list, decide and test what should happen (a controlled error? 0?).
3. With decimals, be careful when comparing float: don't use plain ==, look into how to compare approximately.
4. Use parametrization to cover several cases with a single test.
This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.
Desafíos de programación atemporales y multiparadigmáticos
Te encuentras ante un librillo de actividades, divididas en 2 niveles de dificultad. Te enfrentarás a los casos más comunes que te puedes encontrar en pruebas técnicas o aprender conceptos elementales de programación.
Buy the bookWill you buy me a coffee?
This is how I keep writing without ads or paywalls.
Sure, it's on me!
Comments
There are no comments yet.