diff --git a/README.md b/README.md index 899926c..b5e66a7 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Django styleguide that we use in [HackSoft](https://hacksoft.io). * [Naming convention](#naming-convention) * [Modules](#modules) * [Selectors](#selectors) + * [Testing](#testing-1) - [APIs & Serializers](#apis--serializers) * [Naming convention](#naming-convention-1) * [An example list API](#an-example-list-api) @@ -38,14 +39,8 @@ Django styleguide that we use in [HackSoft](https://hacksoft.io). * [Raising Exceptions in Services / Selectors](#raising-exceptions-in-services--selectors) * [Handle Exceptions in APIs](#handle-exceptions-in-apis) * [Error formatting](#error-formatting) -- [Testing](#testing-1) +- [Testing](#testing-2) * [Naming conventions](#naming-conventions) - * [Example](#example) - + [Example models](#example-models) - + [Example selectors](#example-selectors) - + [Example services](#example-services) - * [Testing services](#testing-services) - * [Testing selectors](#testing-selectors) - [Celery](#celery) * [Structure](#structure) + [Configuration](#configuration) @@ -469,6 +464,112 @@ As you can see, `users_get_visible_for` is another selector. You can return querysets, or lists or whatever makes sense to your specific case. +### Testing + +Since services hold our business logic, they are an ideal candidate for tests. + +If you decide to cover the service layer with tests, we have few general rules of thumb to follow: + +1. The tests should cover the business logic behind the services in an exhaustive manner. +1. The tests should hit the database - creating & reading from it. +1. The tests should mock async task calls & everything that goes outside the project. + +When creating the required state for a given test, one can use a combination of: + +* Fakes (We recommend using [`faker`](https://github.com/joke2k/faker)) +* Other services, to create the required objects. +* Special test utility & helper methods. +* Factories (We recommend using [`factory_boy`](https://factoryboy.readthedocs.io/en/latest/orms.html)) +* Plain `Model.objects.create()` calls, if factories are not yet introduced in the project. +* Usually, whatever suits you better. + +**Let's take a look at our service from the example:** + +```python +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError + +from project.payments.selectors import items_get_for_user +from project.payments.models import Item, Payment +from project.payments.tasks import payment_charge + + +def item_buy( + *, + item: Item, + user: User, +) -> Payment: + if item in items_get_for_user(user=user): + raise ValidationError(f'Item {item} already in {user} items.') + + payment = Payment.objects.create( + item=item, + user=user, + successful=False + ) + + payment_charge.delay(payment_id=payment.id) + + return payment +``` + +The service: + +* Calls a selector for validation. +* Creates an object. +* Delays a task. + +**Those are our tests:** + +```python +from unittest.mock import patch + +from django.test import TestCase +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError + +from django_styleguide.payments.services import item_buy +from django_styleguide.payments.models import Payment, Item + + +class ItemBuyTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='Test User') + self.item = Item.objects.create( + name='Test Item', + description='Test Item description', + price=10.15 + ) + + @patch('project.payments.services.items_get_for_user') + def test_buying_item_that_is_already_bought_fails(self, items_get_for_user_mock): + """ + Since we already have tests for `items_get_for_user`, + we can safely mock it here and give it a proper return value. + """ + items_get_for_user_mock.return_value = [self.item] + + with self.assertRaises(ValidationError): + item_buy(user=self.user, item=self.item) + + @patch('project.payments.services.payment_charge.delay') + def test_buying_item_creates_a_payment_and_calls_charge_task( + self, + payment_charge_mock + ): + self.assertEqual(0, Payment.objects.count()) + + payment = item_buy(user=self.user, item=self.item) + + self.assertEqual(1, Payment.objects.count()) + self.assertEqual(payment, Payment.objects.first()) + + self.assertFalse(payment.successful) + + payment_charge_mock.assert_called() +``` + + ## APIs & Serializers When using services & selectors, all of your APIs should look simple & identical. @@ -1101,286 +1202,6 @@ If we are to split the `utils.py` module into submodules, the same will happen f We try to match the structure of our modules with the structure of their respective tests. -### Example - -We have a demo `django_styleguide` project. - -#### Example models - -```python -import uuid - -from django.db import models -from django.contrib.auth.models import User -from django.utils import timezone - -from djmoney.models.fields import MoneyField - - -class Item(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - - name = models.CharField(max_length=255) - description = models.TextField() - - price = MoneyField( - max_digits=14, - decimal_places=2, - default_currency='EUR' - ) - - def __str__(self): - return f'Item {self.id} / {self.name} / {self.price}' - - -class Payment(models.Model): - item = models.ForeignKey( - Item, - on_delete=models.CASCADE, - related_name='payments' - ) - - user = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='payments' - ) - - successful = models.BooleanField(default=False) - - created_at = models.DateTimeField(default=timezone.now) - - def __str__(self): - return f'Payment for {self.item} / {self.user}' -``` - -#### Example selectors - -For implementation of `QuerySetType`, check `queryset_type.py`. - -```python -from django.contrib.auth.models import User - -from django_styleguide.common.types import QuerySetType - -from django_styleguide.payments.models import Item - - -def get_items_for_user( - *, - user: User -) -> QuerySetType[Item]: - return Item.objects.filter(payments__user=user) -``` - -#### Example services - -```python -from django.contrib.auth.models import User -from django.core.exceptions import ValidationError - -from django_styleguide.payments.selectors import get_items_for_user -from django_styleguide.payments.models import Item, Payment -from django_styleguide.payments.tasks import charge_payment - - -def buy_item( - *, - item: Item, - user: User, -) -> Payment: - if item in get_items_for_user(user=user): - raise ValidationError(f'Item {item} already in {user} items.') - - payment = Payment.objects.create( - item=item, - user=user, - successful=False - ) - - charge_payment.delay(payment_id=payment.id) - - return payment -``` - -### Testing services - -Service tests are the most important tests in the project. Usually, those are the heavier tests with most lines of code. - -General rule of thumb for service tests: - -* The tests should cover the business logic behind the services in an exhaustive manner. -* The tests should hit the database - creating & reading from it. -* The tests should mock async task calls & everything that goes outside the project. - -When creating the required state for a given test, one can use a combination of: - -* Fakes (We recommend using [`faker`](https://github.com/joke2k/faker)) -* Other services, to create the required objects. -* Special test utility & helper methods. -* Factories (We recommend using [`factory_boy`](https://factoryboy.readthedocs.io/en/latest/orms.html)) -* Plain `Model.objects.create()` calls, if factories are not yet introduced in the project. - -**Let's take a look at our service from the example:** - -```python -from django.contrib.auth.models import User -from django.core.exceptions import ValidationError - -from django_styleguide.payments.selectors import get_items_for_user -from django_styleguide.payments.models import Item, Payment -from django_styleguide.payments.tasks import charge_payment - - -def buy_item( - *, - item: Item, - user: User, -) -> Payment: - if item in get_items_for_user(user=user): - raise ValidationError(f'Item {item} already in {user} items.') - - payment = Payment.objects.create( - item=item, - user=user, - successful=False - ) - - charge_payment.delay(payment_id=payment.id) - - return payment - -``` - -The service: - -* Calls a selector for validation -* Creates ORM object -* Calls a task - -**Those are our tests:** - -```python -from unittest.mock import patch - -from django.test import TestCase -from django.contrib.auth.models import User -from django.core.exceptions import ValidationError - -from django_styleguide.payments.services import buy_item -from django_styleguide.payments.models import Payment, Item - - -class BuyItemTests(TestCase): - def setUp(self): - self.user = User.objects.create_user(username='Test User') - self.item = Item.objects.create( - name='Test Item', - description='Test Item description', - price=10.15 - ) - - self.service = buy_item - - @patch('django_styleguide.payments.services.get_items_for_user') - def test_buying_item_that_is_already_bought_fails(self, get_items_for_user_mock): - """ - Since we already have tests for `get_items_for_user`, - we can safely mock it here and give it a proper return value. - """ - get_items_for_user_mock.return_value = [self.item] - - with self.assertRaises(ValidationError): - self.service(user=self.user, item=self.item) - - @patch('django_styleguide.payments.services.charge_payment.delay') - def test_buying_item_creates_a_payment_and_calls_charge_task( - self, - charge_payment_mock - ): - self.assertEqual(0, Payment.objects.count()) - - payment = self.service(user=self.user, item=self.item) - - self.assertEqual(1, Payment.objects.count()) - self.assertEqual(payment, Payment.objects.first()) - - self.assertFalse(payment.successful) - - charge_payment_mock.assert_called() -``` - -### Testing selectors - -Testing selectors is also an important part of every project. - -Sometimes, the selectors can be really straightforward, and if we have to "cut corners", we can omit those tests. But it the end, it's important to cover our selectors too. - -Let's take another look at our example selector: - -```python -from django.contrib.auth.models import User - -from django_styleguide.common.types import QuerySetType - -from django_styleguide.payments.models import Item - - -def get_items_for_user( - *, - user: User -) -> QuerySetType[Item]: - return Item.objects.filter(payments__user=user) -``` - -As you can see, this is a very straightforward & simple selector. We can easily cover that with 2 to 3 tests. - -**Here are the tests:** - -```python -from django.test import TestCase -from django.contrib.auth.models import User - -from django_styleguide.payments.selectors import get_items_for_user -from django_styleguide.payments.models import Item, Payment - - -class GetItemsForUserTests(TestCase): - def test_selector_returns_nothing_for_user_without_items(self): - """ - This is a "corner case" test. - We should get nothing if the user has no items. - """ - user = User.objects.create_user(username='Test User') - - expected = [] - result = list(get_items_for_user(user=user)) - - self.assertEqual(expected, result) - - def test_selector_returns_item_for_user_with_that_item(self): - """ - This test will fail in case we change the model structure. - """ - user = User.objects.create_user(username='Test User') - - item = Item.objects.create( - name='Test Item', - description='Test Item description', - price=10.15 - ) - - Payment.objects.create( - item=item, - user=user - ) - - expected = [item] - result = list(get_items_for_user(user=user)) - - self.assertEqual(expected, result) -``` - ## Celery We use [Celery](http://www.celeryproject.org/) for the following general cases: