تست یعنی جرئت تغییر دادن
پروژهای که تست ندارد بعد از شش ماه «دستنزدنی» میشود: هر تغییر ممکن است جای دیگری را بشکند و کسی جرئت نمیکند نسخهی جنگو را بهروز کند. تستهای جنگو روی یک پایگاه دادهی موقت اجرا میشوند و هر تست داخل یک تراکنش است که در پایان rollback میشود؛ پس تستها روی هم اثر نمیگذارند.
TestCase و Client داخلی جنگو
# apps/orders/tests/test_views.py
from django.test import TestCase
from django.urls import reverse
from apps.accounts.models import User
from apps.orders.models import Customer, Order
class OrderViewsTests(TestCase):
@classmethod
def setUpTestData(cls): # یک بار برای کل کلاس
cls.user = User.objects.create_user(mobile="09120000001", password="pass-12345")
cls.other = User.objects.create_user(mobile="09120000002", password="pass-12345")
cls.customer = Customer.objects.create(user=cls.user, full_name="علی رضایی")
cls.order = Order.objects.create(customer=cls.customer, total=120_000_000)
def test_login_required(self):
resp = self.client.get(reverse("orders:list"))
self.assertRedirects(resp, f"{reverse('login')}?next={reverse('orders:list')}")
def test_owner_sees_order(self):
self.client.force_login(self.user)
resp = self.client.get(self.order.get_absolute_url())
self.assertContains(resp, self.order.tracking_code)
def test_other_user_gets_404(self):
self.client.force_login(self.other)
resp = self.client.get(self.order.get_absolute_url())
self.assertEqual(resp.status_code, 404)
def test_list_query_count(self):
self.client.force_login(self.user)
with self.assertNumQueries(4): # session، user، count، list
self.client.get(reverse("orders:list"))
python manage.py test apps.orders --parallel
pytest-django و factory_boy
pytest خواناتر است، fixture دارد و خروجی خطایش بهتر است. factory_boy هم ساخت دادهی آزمایشی را از تکرار نجات میدهد:
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py
addopts = --reuse-db -q
# apps/orders/tests/factories.py
import factory
from apps.accounts.models import User
from apps.orders.models import Customer, Order
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
mobile = factory.Sequence(lambda n: f"0912{n:07d}")
class CustomerFactory(factory.django.DjangoModelFactory):
class Meta:
model = Customer
user = factory.SubFactory(UserFactory)
full_name = factory.Faker("name", locale="fa_IR")
class OrderFactory(factory.django.DjangoModelFactory):
class Meta:
model = Order
customer = factory.SubFactory(CustomerFactory)
total = 50_000_000
# apps/orders/tests/test_services.py
import pytest
from apps.orders.services import OutOfStock, place_order
@pytest.mark.django_db
def test_out_of_stock_rolls_back(carpet_factory):
carpet = carpet_factory(stock=1)
customer = CustomerFactory()
with pytest.raises(OutOfStock):
place_order(customer, [{"carpet_id": carpet.pk, "quantity": 2}])
assert customer.orders.count() == 0
(carpet_factory با ثبت factory در conftest.py از طریق پلاگین pytest-factoryboy یا یک fixture ساده ساخته میشود.)
نکتههایی که کمتر کسی میداند
setUpTestDataداده را یک بار برای کل کلاس میسازد و از Django 3.2 هر تست یک کپی ایزوله از اشیا میگیرد؛ چند برابر سریعتر ازsetUp.force_loginمراحل هش رمز را رد میکند و تستها را محسوس سریعتر میکند؛ برای سرعت بیشتر در تنظیمات تستMD5PasswordHasherبگذارید.TestCaseمعمولیon_commitرا اجرا نمیکند چون تراکنش هرگز commit نمیشود؛ باself.captureOnCommitCallbacks(execute=True)آنها را اجرا و بررسی کنید.- در pytest-django فیکسچر
django_assert_num_queriesهمان assertNumQueries است وsettingsفیکسچری است که تنظیمات را فقط برای یک تست تغییر میدهد. - برای تست Celery،
CELERY_TASK_ALWAYS_EAGER = Trueدر تنظیمات تست taskها را همزمان اجرا میکند؛ بدون نیاز به Redis در CI.