Introduction
I consider testing to be a prerequisite of quality software. However, testing code can be challenging, especially if the code is not well designed. Prior to testing your code it is good to think about how it should be structured so that it is actually testable.
How should I write my unit tests?
When developing software in Python I often found myself struggling with unit tests. Understanding what behaviour was being asserted and what data was being set up to support it took more time than it should. Although this was because of badly written tests, it is easier than you think to include unnecessary information in your test cases.
This clutter mainly comes from code that creates objects, sets state, or does some work needed before the actual assertion. I believe a unit test should be as minimal as possible — containing only the assertion. Object creation and data input should be delegated to test-infrastructure code. While you can write your own, that is what libraries are for.
The prototype of unittest-extensions
After working with Ruby on Rails and its RSpec testing framework for a while, I thought it would be cool to have something similar in Python. So I started creating a minimal library that extends Python's standard unittest. I don't expect this small extension to be as broad or successful as RSpec — I developed it to use in my own personal projects where I wanted this testing style.
Example
Suppose you have a simple User class:
from dataclasses import dataclass
@dataclass
class User:
name: str
surname: str
def is_relative_to(self, user: "User") -> bool:
return self.surname.casefold() == user.surname.casefold()
This is a dummy example — don't focus on the implementation, only on how to test it. Say we'd like to test is_relative_to.
With unittest
from unittest import main, TestCase
class TestIsRelativeToSameName(TestCase):
def test_same_name(self):
user1 = User("Niklas", "Strindberg")
user2 = User("Niklas", "Ibsen")
self.assertFalse(user1.is_relative_to(user2))
def test_same_empty_name(self):
user1 = User("", "Strindberg")
user2 = User("", "Ibsen")
self.assertFalse(user1.is_relative_to(user2))
class TestIsRelativeToSameSurname(TestCase):
def test_same_surname(self):
user1 = User("August", "Nietzsche")
user2 = User("Henrik", "Nietzsche")
self.assertTrue(user1.is_relative_to(user2))
def test_same_empty_surname(self):
user1 = User("August", "")
user2 = User("Henrik", "")
self.assertTrue(user1.is_relative_to(user2))
def test_same_surname_case_sensitive(self):
user1 = User("August", "NiEtZsChE")
user2 = User("Henrik", "nietzsche")
self.assertTrue(user1.is_relative_to(user2))
def test_surname1_contains_surname2(self):
user1 = User("August", "Solzenietzsche")
user2 = User("Henrik", "Nietzsche")
self.assertFalse(user1.is_relative_to(user2))
if __name__ == "__main__":
main()
Each test method contains two lines where the objects are created — this is the information clutter. The only data we really care about in the first test case is the names, and in the second it's the surnames. Imagine a more realistic User class with dozens of attributes: would we have to witness the creation of fully populated objects in every test method? Even with factory methods, we're still providing unneeded information. The reader should not care about how objects are constructed, only that they are valid.
The point is that, in the second test case, the only things a reader wants to see are surname1, surname2, and the assertion. It is the test-writer's responsibility to relieve readers of everything else.
With unittest-extensions
from unittest import main
from unittest_extensions import TestCase, args
class TestIsRelativeToSameName(TestCase):
def subject(self, name1, name2):
return User(name1, "Strindberg").is_relative_to(User(name2, "Ibsen"))
@args({"name1": "Niklas", "name2": "Niklas"})
def test_same_name(self):
self.assertResultFalse()
@args({"name1": "", "name2": ""})
def test_same_empty_name(self):
self.assertResultFalse()
class TestIsRelativeToSameSurname(TestCase):
def subject(self, surname1, surname2):
return User("August", surname1).is_relative_to(User("Henrik", surname2))
@args({"surname1": "Nietzsche", "surname2": "Nietzsche"})
def test_same_surname(self):
self.assertResultTrue()
@args({"surname1": "", "surname2": ""})
def test_same_empty_surname(self):
self.assertResultTrue()
@args({"surname1": "NiEtZsChE", "surname2": "Nietzsche"})
def test_same_surname_case_sensitive(self):
self.assertResultTrue()
@args({"surname1": "Nietzsche", "surname2": "Solszenietzsche"})
def test_surname2_contains_surname1(self):
self.assertResultFalse()
if __name__ == "__main__":
main()
Examining each test method here, you are not busied with how the objects are created — only with the relevant attributes and the assertion.
Conclusion
unittest-extensions does not provide a wide range of new functionality, nor does it promise to liberate you from thinking hard about how to test your code. It merely guides you toward writing tests that are more concise, readable, and clear.