mirror of
https://github.com/HackSoftware/Django-Styleguide.git
synced 2024-11-22 09:36:36 +03:00
Add section about testing in Services
- Remove examples for testing services / selectors from the `Testing` section
This commit is contained in:
parent
10eaba3319
commit
ce05847710
395
README.md
395
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:
|
||||
|
|
Loading…
Reference in New Issue
Block a user