-
Notifications
You must be signed in to change notification settings - Fork 21
Automated tests: examples within the shifts app
Here we'll have a look at some existing tests and try to explain them, hoping to make it easier to get started writing tests for people who are not used to it.
We'll start with tests relative to the FrozenStatusService
. They are in tapir.shifts.tests.test_FrozenStatusService.TestFrozenStatusService
. Since this file may change after this page is written, here are the links to the service file and to the test file as they are at the time of writing. Concrete examples will be copied from that version.
Let's go through the test file from top to bottom and stop at interesting lines.
class TestFrozenStatusService(TapirFactoryTestBase):
Most of our test classes inherit from tapir.utils.tests_utils.TapirFactoryTestBase
, which itself inherits from django.test.testcases.TestCase
.
TapirFactoryTestBase
adds a few helper function to login with as a Vorstand or Member office user. It also sets the randomization seed for all Factories, so that tests generate the exact same test objects on every run.
NOW = datetime.datetime(year=2020, month=1, day=30, hour=16, minute=37)
def setUp(self) -> None:
mock_timezone_now(self, self.NOW)
setUp()
is run automatically before each test. Since the FrozenStatusService
depends on the current time, we "mock" the current time using mock_timezone_now
. See time-independance. That means that we replace the time function that Django would otherwise use by a temporary one that always returns the same value. This makes our tests more stable.
def test_shouldFreezeMember_memberAlreadyFrozen_returnsFalse(self):
shift_user_data = ShiftUserData()
shift_user_data.attendance_mode = ShiftAttendanceMode.FROZEN
self.assertFalse(FrozenStatusService.should_freeze_member(shift_user_data))
We start by looking at FrozenStatusService.should_freeze_member
, that tells us if a member should, well, get frozen. If a member is already frozen, they should not get re-frozen: that would create a log entry, send them an email...
The function name follows our naming convention:
-
test
every test function starts with "test -
shouldFreezeMember
then comes the name of the element that we are testing. Here we put the method name, the name of the service is already specified in the test file name and the test class name. -
memberAlreadyFrozen
this is the "case" that we are testing, that is the context in which we are testing -
returnsFalse
this is the expected result.
This test follows the usual pattern of "given, when, then":
- Given a ShiftUserData that is already frozen, this is the "state of the world" that the test runs in
- When we call the
should_freeze_member
function, this is the action that we perform - Then the result should be False, this is the expected result
The "given" part is usually done by mocking functions and calling factories: we create the objects that let us define the starting situation.
The "when" part is usually a function call or a client request (there is no client request in this test file, we'll look at a separate one after).
The "then" part is usually a sequence of self.assert...
. Since we inherit from TestCase
we have access to a many variants of self.assert
. Using the appropriate one produces more readable messages when the test fails.
@patch.object(ShiftExpectationService, "is_member_expected_to_do_shifts")
def test_shouldFreezeMember_memberIsNotExpectedToDoShifts_returnsFalse(
self, mock_is_member_expected_to_do_shifts: Mock
):
shift_user_data = ShiftUserData()
shift_user_data.attendance_mode = ShiftAttendanceMode.REGULAR
mock_is_member_expected_to_do_shifts.return_value = False
self.assertFalse(FrozenStatusService.should_freeze_member(shift_user_data))
mock_is_member_expected_to_do_shifts.assert_called_once_with(
shift_user_data, self.NOW.date()
)
This time we have a test we actual mocking. That's because we now rely on another service that is outside FrozenStatusService
:
class FrozenStatusService:
@classmethod
def should_freeze_member(cls, shift_user_data: ShiftUserData) -> bool:
if shift_user_data.attendance_mode == ShiftAttendanceMode.FROZEN:
return False
if not ShiftExpectationService.is_member_expected_to_do_shifts(
shift_user_data, timezone.now().date()
):
return False
# More checks
return True
In our first test, we had should_freeze_member
return on the first check, so the setup was minimal. Now we want to go up to the second check, so we setup our ShiftUserData
object to pass the first check with shift_user_data.attendance_mode = ShiftAttendanceMode.REGULAR
.
Then is the more interesting part. Members who are not expected to do shifts (for example because they are exempted) should not be frozen. This is determined by ShiftExpectationService.is_member_expected_to_do_shifts
. Instead of mocking this function, we could define our shift_user_data
so that is_member_expected_to_do_shifts
returns True, but that would have two disadvantages:
- If the requirements inside
is_member_expected_to_do_shifts
, our test could fail even the part that we are currently testing didn't change. We want as few tests as possible to fail, so that it is easier to pinpoint which part of the code is failing. - In the "given" part of our test, we would need to define more parameters of our
shift_user_data
. This would make it less clears which parts are actually relevant to the test. Instead, we mockis_member_expected_to_do_shifts
: we replace it with a mock object. We define inside the test how we want that mock object to behave.
We declare with the @patch.object
decorator that we want to mock the function. This adds a parameter to our test function (in this case called mock_is_member_expected_to_do_shifts
but only the position is important). Then, with mock_is_member_expected_to_do_shifts.return_value = False
, we say that is_member_expected_to_do_shifts
will always return False, thus making this test independant of the internal working of is_member_expected_to_do_shifts
.
The assert is then done as usual.
The last line may not be very intuitive but it is still important: mock.assert_called_once_with(...)
. Even though we told our mock objects to return False regardless of what parameters we call it with, it still keeps a history of how many times it gets called, and with which parameter. Thus, with assert_called_once_with(...)
, we can make sure that the function is called as expected. For example, if we replaced the first check with ... == ShiftAttendanceMode.FLYING
and didn't call assert_called_once_with
, the test would still pass, but we would not have tested the lines that we actually want to test.
There are many variants of assert_called_...
that let you check the number of calls, the parameters of each call, the order...
Let's peek at the following test:
@patch.object(ShiftExpectationService, "is_member_expected_to_do_shifts")
@patch.object(FrozenStatusService, "_is_member_below_threshold_since_long_enough")
def test_shouldFreezeMember_memberNotBelowThreshold_returnsFalse(
self,
mock_is_member_below_threshold_since_long_enough: Mock,
mock_is_member_expected_to_do_shifts: Mock,
):
You'll notice that the parameter order is reversed relative to the decorators: @patch("is_member_expected_to_do_shifts")
comes first, but the corresponding mock object is the second parameter. Naming the parameters in full helps avoid errors.
Testing views is similar to testing services, it just has have few specificities. Let's look at tapir.shifts.tests.test_slot_template_create.TestSlotTemplateCreate.test_creating_a_slot_template_also_creates_the_slot_in_future_shifts
.
def test_creating_a_slot_template_also_creates_the_slot_in_future_shifts(self):
self.login_as_member_office_user()
When testing views, we reproduce as much as possible the behaviour we would have when visiting the view from a browser. In most cases, we need to login to visit a view. TapirFactoryTestBase
gives us a few shortcuts to login as a member with specific access rights, in this example self.login_as_member_office_user()
.
A test client is created for each test in tapir.utils.tests_utils.TapirFactoryTestBase.setUp
. This client is like the browser we'll be testing with: it can visit URLs and present us with the result.
We can do POST or GET requests. In our example it's a POST:
response = self.client.post(
reverse(self.SLOT_TEMPLATE_CREATE_VIEW, args=[shift_template.id]),
{
"name": self.SLOT_TEMPLATE_NAME,
"required_capabilities": required_capabilities,
"warnings": warnings,
"check_update_future_shifts": True,
},
follow=True,
)
Let's go through it step by step.
reverse
gives us the URL corresponding to a view name, as defined in urls.py
files.
The second parameter to self.client.post
is the post data. That's the data that the user would have filled in the corresponding form.
For GET requests, there is no post data.
After visiting a view, we almost always check for the status code first. In our example, we expect the call to succeed, so we expect 200: self.assertEqual(response.status_code, 200)
.
If the call should have modified database objects, you can fetch them or, if you already had a reference to them like in our example, use refresh_from_db()
. Then do some self.assertEqual
as usual.
If you need to check the content of HTML return by the visit, you can use response.content.decode()
to get the page content as a string. Sometimes though, check for HTML can be tedious. It is also very brittle: a change in the page layout could lead the test to fail, even though the logic is still fine. It can be more convenient to check the context data, as returned by the views get_context_data()
, with response.context["..."]
.