Add section about testing in Services

- Remove examples for testing services / selectors from the `Testing` section
This commit is contained in:
Radoslav Georgiev 2021-09-25 19:24:54 +03:00
parent 10eaba3319
commit ce05847710
No known key found for this signature in database
GPG Key ID: 0B7753A4DFCE646D

395
README.md
View File

@ -24,6 +24,7 @@ Django styleguide that we use in [HackSoft](https://hacksoft.io).
* [Naming convention](#naming-convention) * [Naming convention](#naming-convention)
* [Modules](#modules) * [Modules](#modules)
* [Selectors](#selectors) * [Selectors](#selectors)
* [Testing](#testing-1)
- [APIs & Serializers](#apis--serializers) - [APIs & Serializers](#apis--serializers)
* [Naming convention](#naming-convention-1) * [Naming convention](#naming-convention-1)
* [An example list API](#an-example-list-api) * [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) * [Raising Exceptions in Services / Selectors](#raising-exceptions-in-services--selectors)
* [Handle Exceptions in APIs](#handle-exceptions-in-apis) * [Handle Exceptions in APIs](#handle-exceptions-in-apis)
* [Error formatting](#error-formatting) * [Error formatting](#error-formatting)
- [Testing](#testing-1) - [Testing](#testing-2)
* [Naming conventions](#naming-conventions) * [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) - [Celery](#celery)
* [Structure](#structure) * [Structure](#structure)
+ [Configuration](#configuration) + [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. 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 ## APIs & Serializers
When using services & selectors, all of your APIs should look simple & identical. 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. 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 ## Celery
We use [Celery](http://www.celeryproject.org/) for the following general cases: We use [Celery](http://www.celeryproject.org/) for the following general cases: