Integration Testing
This guide explains how to write integration tests for the Gyrinx application using Django's test client and pytest.
Overview
Integration tests verify complete user workflows through the UI, ensuring all components work together correctly. They use Django's test client to simulate HTTP requests and responses, testing the full stack from URLs to views to models.
Test Structure
Integration tests follow these conventions:
File naming: Use
test_integration_*.py
for integration test filesFunction naming: Use descriptive names that explain the workflow being tested
Decorators: Always use
@pytest.mark.django_db
for tests that access the database
Basic Pattern
@pytest.mark.django_db
def test_user_workflow(client, user, other_fixtures):
"""Test description of what workflow is being tested."""
# 1. Login user if authentication is required
client.force_login(user)
# 2. Make GET request to view page
response = client.get(reverse("app:view-name", args=[obj.id]))
assert response.status_code == 200
assert "Expected content" in response.content.decode()
# 3. Make POST request to submit form
response = client.post(
reverse("app:action-name", args=[obj.id]),
{
"field1": "value1",
"field2": "value2",
}
)
assert response.status_code == 302 # Redirect after success
# 4. Verify database changes
obj.refresh_from_db()
assert obj.field1 == "value1"
# 5. Verify UI reflects changes
response = client.get(reverse("app:view-name", args=[obj.id]))
assert "value1" in response.content.decode()
Available Fixtures
The project provides several fixtures in conftest.py
:
client
: Django test client for making HTTP requestsuser
: A test user instancemake_user
: Factory for creating userscontent_house
: A ContentHouse instancemake_content_house
: Factory for creating housescontent_fighter
: A ContentFighter instancemake_content_fighter
: Factory for creating fightersmake_list
: Factory for creating listsmake_list_fighter
: Factory for creating list fightersmake_equipment
: Factory for creating equipmentmake_weapon_profile
: Factory for creating weapon profilesmake_weapon_accessory
: Factory for creating weapon accessories
Common Test Scenarios
Testing Authentication
@pytest.mark.django_db
def test_view_requires_login(client, user):
"""Test that view redirects to login for anonymous users."""
url = reverse("core:protected-view")
# Test anonymous access
response = client.get(url)
assert response.status_code == 302
assert "/accounts/login/" in response.url
# Test authenticated access
client.force_login(user)
response = client.get(url)
assert response.status_code == 200
Testing Form Submissions
@pytest.mark.django_db
def test_form_submission(client, user):
"""Test form validation and processing."""
client.force_login(user)
# Test invalid form
response = client.post(
reverse("core:form-view"),
{"invalid": "data"}
)
assert response.status_code == 200 # Stays on form page
assert "This field is required" in response.content.decode()
# Test valid form
response = client.post(
reverse("core:form-view"),
{"required_field": "value"}
)
assert response.status_code == 302 # Redirects on success
Testing Permissions
@pytest.mark.django_db
def test_ownership_required(client, user, other_user, make_list):
"""Test that only owners can modify their objects."""
lst = make_list("Test List", owner=other_user)
client.force_login(user)
response = client.get(reverse("core:list-edit", args=[lst.id]))
assert response.status_code == 404 # Not found for non-owner
Testing Search and Filtering
@pytest.mark.django_db
def test_search_functionality(client):
"""Test search filters work correctly."""
# Create test data
Equipment.objects.create(name="Bolt Pistol", category="Pistols")
Equipment.objects.create(name="Plasma Gun", category="Special")
# Test search
response = client.get(reverse("core:equipment-list") + "?search=bolt")
assert "Bolt Pistol" in response.content.decode()
assert "Plasma Gun" not in response.content.decode()
# Test filter
response = client.get(reverse("core:equipment-list") + "?category=Special")
assert "Plasma Gun" in response.content.decode()
assert "Bolt Pistol" not in response.content.decode()
Best Practices
Test complete workflows: Integration tests should cover entire user journeys, not just individual views
Use descriptive assertions: Check for specific content in responses to ensure the right template and data are rendered
Test error cases: Verify that invalid inputs are handled gracefully
Clean test data: Tests should create their own data and not depend on existing database state
Test permissions: Always verify that unauthorized users cannot access protected resources
Use factories: Leverage the provided fixture factories to create test data consistently
Running Integration Tests
Run all integration tests:
pytest gyrinx/core/tests/test_integration_*.py
Run a specific test:
pytest gyrinx/core/tests/test_integration_core_functionality.py::test_create_list_and_add_fighter
Run with verbose output:
pytest -v gyrinx/core/tests/test_integration_*.py
Debugging Tips
Print response content: When a test fails, print
response.content.decode()
to see the actual HTMLCheck redirects: Use
response.url
to see where a redirect is goingExamine form errors: Access
response.context['form'].errors
to see validation errorsDatabase state: Use
Model.objects.all()
to verify database changesUse pdb: Add
import pdb; pdb.set_trace()
to debug interactively
Example: Complete Integration Test
Here's a complete example testing the fighter equipment workflow:
@pytest.mark.django_db
def test_fighter_equipment_workflow(client, user, make_list, make_list_fighter, make_equipment):
"""Test complete workflow of managing fighter equipment."""
# Setup
lst = make_list("Test Gang")
fighter = make_list_fighter(lst, "Fighter")
weapon = make_equipment("Lasgun", category="Basic Weapons", cost=15)
client.force_login(user)
# View fighter equipment page
response = client.get(reverse("core:fighter-equipment", args=[fighter.id]))
assert response.status_code == 200
assert "Lasgun" in response.content.decode()
assert "15 credits" in response.content.decode()
# Add equipment
response = client.post(
reverse("core:fighter-equipment-add", args=[fighter.id]),
{"equipment": weapon.id}
)
assert response.status_code == 302
# Verify equipment was added
fighter.refresh_from_db()
assert fighter.equipment.count() == 1
assert fighter.equipment.first() == weapon
# View fighter detail page
response = client.get(reverse("core:fighter", args=[fighter.id]))
assert response.status_code == 200
assert "Lasgun" in response.content.decode()
# Remove equipment
assignment = fighter.assignments()[0]
response = client.post(
reverse("core:fighter-equipment-remove", args=[fighter.id, assignment.id])
)
assert response.status_code == 302
# Verify equipment was removed
fighter.refresh_from_db()
assert fighter.equipment.count() == 0
This example demonstrates:
Setting up test data with fixtures
Making authenticated requests
Testing multiple related views
Verifying database changes
Checking UI updates reflect data changes
Last updated