Kryštof Řeháček
Posted on June 10, 2024
I would like to share a pattern for unit testing that I discovered while reading through the repository of one of our dependencies. It's about testing through object representation.
Problems with Testing Code
I perceive many problems with testing, but two main ones stand out:
- It’s difficult to write unit tests that test what they should and don't degrade over time.
- It’s hard to write unit tests quickly.
Our mindset is to develop features as quickly as possible, even at the cost of sometimes breaking things. We don't have the capacity or appetite for 100% test coverage. This post is for similarly-minded programmers.
Senior developers are here to create well-structured designs and deliver features. Therefore, they don't have time to write good tests and delegate such work to junior colleagues.
Juniors don't know how to properly test code, so they test everything they can think of, as they were taught in school. What they do is just cover the code in concrete.
Tests become unreadable in half a year, making it hard to understand what they test. If changes are made to the "concreted code" later, tests break, requiring fixes. If they aren't readable, they can't be fixed, and the test rots – it gets deleted or modified just to pass, and the problem grows.
How to Write Simple Tests?
Let's look at the test below. A simple test, checking that the items of the following invoice will be as expected. A very simple example, but it takes a moment to decode what exactly it tests.
def test_subscription_with_usage_first_tier(self):
self.subscription.record_usage(quantity=5, created_at=aware_date(2020, 6, 1))
usage_summary_group = self.subscription.get_upcoming_invoice_item_groups(
aware_date(2020, 6, 1), aware_date(2020, 7, 1)
)
self.assertEqual(usage_summary_group.price, Decimal(4.5))
self.assertEqual(usage_summary_group.currency, "CZK")
self.assertEqual(len(usage_summary_group.items), 1)
self.assertEqual(usage_summary_group.items[0].price, Decimal(5))
self.assertEqual(usage_summary_group.items[0].currency, "CZK")
self.assertEqual(usage_summary_group.items[0].quantity, 5)
self.assertEqual(usage_summary_group.items[0].get_discounted_price(), Decimal(4.5))
self.assertEqual(usage_summary_group.items[0].discount_name, "Discount 10%")
self.assertEqual(usage_summary_group.items[0].discount_percent_off, 10)
An alternative I offer as a solution is to define a __repr__
method for such an object that includes all relevant values.
def test_subscription_with_usage_first_tier(self):
self.subscription.record_usage(quantity=5, created_at=aware_date(2020, 6, 1))
usage_summary_group = self.subscription.get_upcoming_invoice_item_groups(
aware_date(2020, 6, 1), aware_date(2020, 7, 1)
)
self.assertEqual(
repr(usage_summary_group),
"<UpcomingInvoiceItemGroup 4.50 CZK: Product - Tier 1 (x5) 5.00 CZK (Discount 10% = 0.50 CZK)>",
)
The test below tests the same thing as the first test. The difference is that the second test tests the string representation of the object instead of checking all the attributes.
The second test is much faster to write and significantly easier to read. Writing readable tests is one of the key factors in ensuring that a test doesn't rot over time.
But there's a catch. By not testing the object's attributes, there can be an error in the definition of the __repr__
method.
This means that such a solution is a tradeoff. By trusting the __repr__
method, I've written a test that is easy to read and faster to write. However, this could be the difference between having tested code and code for which no test exists.
Conclusion
If you test your code and have no problems with it, you're probably doing it right. However, if you don't have time to write tests, this solution could provide simple, readable, and maintainable tests.
Posted on June 10, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 25, 2024