Django Expense Manager 2.1
Let’s continue writing the tests we wrote here. In there, we set up our database with one User, one Category, one Retailer and one Transaction. I wanted to test some generic combinations including using values that shouldn’t work. Let’s have a look. Rather than creating many fixtures manually, I use a factory that allows me to create customised objects while only declaring some test-specific fields. The one I am using is Factory Boy, specifically the pytest version that makes the factories easy to use as fixtures. How does a factory work? Let’s first look at an example factory for our Users.
User Factory
Creating the user factory
import factory
from django.contrib.auth import get_user_model
User = get_user_model()
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
username = factory.Sequence(lambda n: f'user_{n}')
email = factory.Faker('email')
password = factory.PostGenerationMethodCall(
'set_password', f'{email}_password'
)
We are first subclassing from factory.django.DjangoModelFactory which is a dedicated class for the Django Model factories (in fact factory_boy was initially designed to be used with Django but has since become more framework agnostic). If I now call UserFactory()
, I get a User instance. What is happening in the username, email and password fields? Although not necessary for all the fields (only the ones that have a unique key constraint), when I first call UserFactory(), the User instance created has the username user_0, some email and password {valueofemailfield}_password (some “random” values, don’t judge). If I call the UserFactory() again and get another User instance, it will have the username user_1, some other email and password {valueofemailfield}_password. Every time it gets called during testing the number is incremented by one so that I have unique values. Please note that for
the email field I used Faker which is a library (called by the factory) that generates “realistic” emails.
But what is that PostGenerationMethodCall? Well, the email field does not get generated until then user_factory is actually called. As a result, it won’t have its value yet. After instantiation, however, the password field gets set using the value in the email field.
So how do I use this UserFactory in my test? Well, first, in my conftest.py file in the test directory, I register the UserFactory. That way, I can call user_factory
as a fixture in any of my tests without importing it. In the conftest.py file:
Registering the user factory as a fixture
...
from .factories import UserFactory,
from pytest_factoryboy import register
...
register(UserFactory)
Using the user factory in our tests
Now let’s see how to use this in a test. In my test_users.py file:
...
@pytest.mark.django_db
def test_user_email(user_factory):
user = user_factory()
assert isinstance(user, User)
assert "@" in user.email
assert "user_" in user.username
assert user.username[5].isdigit()
That test will pass. The email faker generates all have a @ in them, they all start with “user_” and the 5th position (with indexing from 0) all have a number generated by the FactoryBoy sequence.
What if we wanted to override some of the default behaviour?
@pytest.mark.django_db
def test_user_factory_override_email(user_factory):
user = user_factory(email="amazing_email@hmail.com")
assert isinstance(user, User)
assert user.email == "amazing_email@hmail.com"
When calling user_factory()
, if we don’t provide any arguments like in the first example, it uses the default values (or rather the methods for generating those values) for the fields of the Model. If we pass in some keyword arguments, however, like overriding the email field, it uses that specific email.
Retailer Factory
Creating the retailer factory
Creating the retailer factory is similar to the User Factory.
class RetailerFactory(factory.django.DjangoModelFactory):
class Meta:
model = Retailer
user = factory.SubFactory(UserFactory)
name = factory.Sequence(lambda n: f'AmazI{n}')
online = True
Oh I just realised I didn’t talk about the SubFactory method of factory. The Retailer, Category and Transaction models all require a User. Consequently, a User instance should be used for the user field. I accomplish that by telling the Retailer Factory to call the User Factory for the user field.
Registering the retailer factory as a fixture
Let’s register it as a fixture in our conftest.py
...
from .factories import RetailerFactory
...
register(RetailerFactory)
Using the retailer factory in our tests
Finally, let’s actually use it for some tests.
from django.db.utils import DataError
from django.core.exceptions import ValidationError
@pytest.mark.django_db
def test_random_retailer_factory(retailer_factory):
retailer = retailer_factory()
assert retailer.online == True
assert "AmazI" in retailer.name
assert retailer.name[-1].isdigit()
assert "@" in retailer.user.email
@pytest.mark.django_db
def test_offline_retailer_factory(retailer_factory):
retailer = retailer_factory(online=False)
assert "AmazI" in retailer.name
assert retailer.name[-1].isdigit()
assert "@" in retailer.user.email
assert retailer.online == False
@pytest.mark.django_db
def test_invalid_user(retailer_factory):
with pytest.raises(ValueError) as exception_info:
retailer_factory(user="invalid")
assert exception_info.type == ValueError
@pytest.mark.django_db
def test_name_too_long(retailer_factory):
with pytest.raises(DataError) as exception_info:
retailer_factory(name="a"*256)
assert exception_info.type == DataError
@pytest.mark.django_db
def test_online_invalid_bool(retailer_factory):
with pytest.raises(ValidationError) as exception_info:
retailer_factory(online="Falsish")
assert exception_info.type == ValidationError
Woooah so many tests. The first one should be relatively straight forward. I am simply testing the default values of the factory. In the second test, I am overriding the default value for the online field and setting it to False. Otherwise, no difference. The last 3 tests is where I’m stressing the values that fields can accept. In test_invalid_user
, rather than passing a User instance for the user field, I am passing a string. I am testing whether Django will in fact raise a ValueError exception and it does. Then, remember our retailer name column in the database has a maximum length of 255? Well, if I pass in a name that’s longer than that, Django will raise a DataError. Finally, the online field has to be a boolean. When I pass in a string (can’t be “False” or “True” since those will be converted to booleans), Django raises a ValidationError exception. Notice how we catch and assert what each of these exceptions are. You can find more information on pytest.raises here.
(Note to self, should I parametrise these tests? Probably yes. Will I do it? I hope so.)
Category Factory
New subheading, new factory. You know how it goes.
Creating the category factory
class CategoryFactory(factory.django.DjangoModelFactory):
class Meta:
model = Category
user = factory.SubFactory(UserFactory)
name = factory.Sequence(lambda n: f'Category_{n}')
product_type = "P"
Nothing new here. The User instance is obtained from the User Factory through the use of a SubFactory.
Registering the category factory as a fixture
...
from .factories import CategoryFactory
...
register(CategoryFactory)
Using the category factory in our tests
from django.db.utils import DataError
from django.core.exceptions import ValidationError
@pytest.mark.django_db
def test_random_category_factory(category_factory):
category = category_factory()
assert category.product_type == "P"
assert "Category_" in category.name
assert category.name[-1].isdigit()
assert "@" in category.user.email
@pytest.mark.django_db
def test_electronic_category_factory(category_factory):
category = category_factory(product_type="E")
assert category.product_type == "E"
assert "Category_" in category.name
assert category.name[-1].isdigit()
assert "@" in category.user.email
@pytest.mark.django_db
def test_invalid_user(category_factory):
with pytest.raises(ValueError) as exception_info:
category_factory(user="invalid")
assert exception_info.type == ValueError
@pytest.mark.django_db
def test_name_too_long(category_factory):
with pytest.raises(DataError) as exception_info:
category_factory(name="a"*256)
assert exception_info.type == DataError
Similar ideas to the tests for Retailers. Nothing new. There is, however, a lot of repetition which is why I will parametrise them at some point. That point is not now.
Transaction Factory
The transaction factory is similar but there will be some subtle difference in the tests.
Creating the transaction factory
class TransactionFactory(factory.django.DjangoModelFactory):
class Meta:
model = Transaction
# need to inject the same user for category and retailer
user = factory.SubFactory(UserFactory)
category = factory.SubFactory(CategoryFactory)
retailer = factory.SubFactory(RetailerFactory)
amount = Money(420.00, "GBP")
name = factory.Sequence(lambda n: f"Transaction_{n}")
date = factory.Faker(
'date',
end_datetime=date.today()
)
transaction_type="E"
recurring = False
Registering the transaction factory as a fixture
Just like before. We import it and register it.
...
from .factories import TransactionFactory
...
register(TransactionFactory)
Using the transaction factory in our tests
Then we test a lot of errors.
...
from django.db.utils import DataError
from django.core.exceptions import ValidationError
from moneyed.classes import CurrencyDoesNotExis
...
@pytest.mark.django_db
def test_transaction_name_too_long(transaction_factory):
with pytest.raises(DataError) as exception_info:
transaction_factory(name="a"*256)
assert exception_info.type == DataError
@pytest.mark.django_db
def test_transaction_fake_currency(transaction_factory):
with pytest.raises(CurrencyDoesNotExist) as exception_info:
transaction_factory(amount=Money(23.023, "ABC"))
assert exception_info.type == CurrencyDoesNotExist
@pytest.mark.django_db
def test_transaction_retailer_invalid(transaction_factory):
with pytest.raises(ValueError) as exception_info:
transaction_factory(retailer="Not a retailer instance")
assert exception_info.type == ValueError
@pytest.mark.django_db
def test_transaction_invalid_date(transaction_factory):
with pytest.raises(ValidationError) as exception_info:
transaction_factory(date="2040-15-42")
assert exception_info.type == ValidationError
@pytest.mark.django_db
def test_transaction_invalid_type(transaction_factory):
with pytest.raises(DataError) as exception_info:
transaction_factory(transaction_type="EX")
assert exception_info.type == DataError
@pytest.mark.django_db
def test_transaction_recurring_bool(transaction_factory):
with pytest.raises(ValidationError) as exception_info:
transaction_factory(recurring="no")
assert exception_info.type == ValidationError
@pytest.mark.django_db
def test_transaction_invalid_user(transaction_factory):
with pytest.raises(ValueError) as exception_info:
transaction_factory(user="Batboy")
assert exception_info.type == ValueError
So all good now? Well, yes and no. While these transaction tests do test the various fields and can also test that the retailer, category and user fields are instances of the Retailer, Category and User models (see below), there is a problem with how the factory fixture is created.
@pytest.mark.django_db
def test_transaction_foreignkeys(transaction_factory):
transaction = transaction_factory()
assert isinstance(transaction.retailer, Retailer)
assert isinstance(transaction.category, Category)
assert isinstance(transaction.user, User)
Consider the following test. It will fail.
@pytest.mark.django_db
def test_transaction_foreignkeys(transaction_factory):
transaction = transaction_factory()
assert transaction.retailer.user == transaction.category.user
assert transaction.retailer.user == transaction.user
What are we doing there? We are creating a transaction calling the transaction factory (which itself calls the retailer, user and category subfactories). The problem lies in those subsequent subfactories. The retailer and user subfactories then themselves call the user subfactory. Consequently, the user instance attached to the retailer is different to the user instance attached to the category and different to the user instance attacehd to the transaction. Problem! oh oh. Obviously when we’d create the Django models that wouldn’t be the case since the user could only choose categories/retailers they themselves created. So how can we express this in our tests? I won’t change the tests already written: after all, they are meant to test the other fields. Let’s change the last fail indicating it will fail using the @pytest.mark.xfail
decorator as such:
@pytest.mark.xfail
@pytest.mark.django_db
def test_transaction_foreignkeys(transaction_factory):
transaction = transaction_factory()
assert transaction.retailer.user == transaction.category.user
assert transaction.retailer.user == transaction.user
Ok so back to our problem. The User instances we have attached to the Transaction, Category and Retailer instances are all different. Let’s create a fixture where they all share the same User instance. Why? Because we can :) In our conftest.py
:
...
# create specific versions
register(UserFactory, "same_user", username="specific_user",
email="specific_user@email.com", password="specific_password")
register(RetailerFactory, "same_user_retailer")
register(CategoryFactory, "same_user_category")
register(TransactionFactory, "same_user_transaction"
First I create specific fixtures usign each of the factories. So there is a same_user fixture that holds those specific values and same_user_retailer, same_user_category and same_user_transaction that for now are just named fixtures of those models but still with the default values from the factories. How do we then overwrite the user instance for the retailer fixture? or the category fixture? or the transaction fixture? Let’s have a look. Please note that these are all specific to pytest factoryboy. The following code is based on this.
...
@pytest.fixture
def same_user_retailer__user(same_user):
return same_user
@pytest.fixture
def same_user_category__user(same_user):
return same_user
@pytest.fixture
def same_user_transaction__user(same_user):
return same_user
...
What happens here? I am basically saying that for the user field of the Retailer, Category and Transaction specific fixtures, they should all return that specific same_user fixture. Last thing to do is change the retailer and category instances associated with that specific transaction to these modified ones.
...
@pytest.fixture
def same_user_transaction__retailer(same_user_retailer):
return same_user_retailer
@pytest.fixture
def same_user_transaction__category(same_user_category):
return same_user_category
Now in our test_transactions.py file:
...
@pytest.mark.parametrize("same_user_transaction__name", ["Transaction_420"])
@pytest.mark.django_db
def test_transaction_factory_specific(same_user_transaction, same_user):
assert same_user_transaction.name == "Transaction_420"
assert same_user_transaction.user == same_user
assert same_user_transaction.category.user == same_user_transaction.user
assert same_user_transaction.retailer.user == same_user_transaction.user
This test will now pass. And that’s the end of this post. In the next one we will start incorporating the Django Rest Framework into our backend. oh and we’ll also add coverage to our tests when we test those.
(Although I might first write another post in the Wagtail series, been a while. And maybe another async one. And maybe a FastAPI one. Gotta keep it interesting for myself.)
See you soon!
Also, the git commit for this post is here