This document provides guidance on testing the CheckTick API, including patterns, best practices, and examples from the existing test suite.
Overview
The CheckTick API is tested using pytest with Django's test client. Tests verify that API endpoints work correctly, handle edge cases, validate inputs, and return appropriate responses.
Test Location
API tests are located in:
- /tests/test_api_*.py - General API tests
- /checktick_app/api/tests/ - App-specific API tests
Running API Tests
Parallel Execution (Recommended)
CheckTick uses pytest-xdist for parallel test execution, which significantly speeds up test runs:
# Run all tests in parallel (recommended - ~14x faster)
docker compose exec web pytest -n auto
# Run all API tests in parallel
docker compose exec web pytest tests/test_api_*.py -n auto
The -n auto flag automatically detects available CPU cores and distributes tests across them. Each worker gets its own database, ensuring test isolation.
Sequential Execution
# Run all API tests
docker compose exec web pytest tests/test_api_*.py
# Run specific test file
docker compose exec web pytest tests/test_api_questions_and_groups.py
# Run with verbose output
docker compose exec web pytest tests/test_api_questions_and_groups.py -v
# Run specific test
docker compose exec web pytest tests/test_api_questions_and_groups.py::TestAPIQuestionsAndGroups::test_seed_text_question
Test Structure
Basic Test Class Pattern
import pytest
import json
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.mark.django_db
class TestMyAPIEndpoint:
"""Test suite for my API endpoint."""
@pytest.fixture
def setup_test_data(self, client):
"""Create test data needed for tests."""
user = User.objects.create_user(username="testuser", password="testpass")
# ... create other test data
# Get JWT token
resp = client.post(
"/api/token",
data=json.dumps({"username": "testuser", "password": "testpass"}),
content_type="application/json",
)
token = resp.json()["access"]
headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
return user, headers
def test_endpoint_success(self, client, setup_test_data):
"""Test successful API call."""
user, headers = setup_test_data
response = client.post(
"/api/my-endpoint/",
data=json.dumps({"key": "value"}),
content_type="application/json",
**headers
)
assert response.status_code == 200
data = response.json()
assert data["key"] == "expected_value"
Authentication and Permissions
CheckTick uses JWT authentication for API requests. Tests should:
- Create a test user
- Obtain a JWT token via
/api/token - Include the token in request headers
Example: JWT Authentication Setup
# Get token
resp = client.post(
"/api/token",
data=json.dumps({"username": "testuser", "password": "testpass"}),
content_type="application/json",
)
token = resp.json()["access"]
headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
# Use in requests
response = client.post("/api/endpoint/", **headers)
Note: Authentication and permission tests are covered in:
- tests/test_api_permissions.py
- tests/test_api_access_controls.py
- tests/test_jwt_auth.py
Refer to these files for examples of testing authentication flows, permission levels, and access control.
Question and Question Group API Tests
The /tests/test_api_questions_and_groups.py file demonstrates comprehensive API testing patterns.
Testing Question Creation (Seeding)
The seed endpoint (POST /api/surveys/{id}/seed/) accepts JSON payloads to create questions.
Example: Basic Question Types
def test_seed_text_question(self, client, setup_basic_survey):
"""Test seeding a basic text question."""
user, survey, headers = setup_basic_survey
url = f"/api/surveys/{survey.id}/seed/"
payload = [
{
"type": "text",
"text": "What is your name?",
"required": True,
"options": [{"type": "text", "format": "free"}]
}
]
response = client.post(
url,
data=json.dumps(payload),
content_type="application/json",
**headers
)
assert response.status_code == 200
data = response.json()
assert "questions" in data
assert len(data["questions"]) == 1
question = SurveyQuestion.objects.get(survey=survey)
assert question.text == "What is your name?"
assert question.type == SurveyQuestion.Types.TEXT
assert question.required is True
Valid Question Types
The API validates question types against these allowed values:
- text - Text input
- mc_single - Multiple choice (single selection)
- mc_multi - Multiple choice (multiple selections)
- dropdown - Dropdown selection
- yesno - Yes/No question
- likert - Likert scale
- orderable - Orderable list
- image - Image choice
- template_patient - Patient template
- template_professional - Professional template
Testing Follow-up Text Feature
Questions can have follow-up text inputs on specific options:
def test_seed_mc_single_with_followup(self, client, setup_basic_survey):
"""Test MC question with follow-up text on one option."""
user, survey, headers = setup_basic_survey
url = f"/api/surveys/{survey.id}/seed/"
payload = [
{
"type": "mc_single",
"text": "How did you hear about us?",
"options": [
"Friend",
"Social Media",
{"text": "Other", "has_followup": True, "followup_label": "Please specify"}
]
}
]
response = client.post(url, data=json.dumps(payload),
content_type="application/json", **headers)
assert response.status_code == 200
question = SurveyQuestion.objects.get(survey=survey)
# Last option should have followup
assert question.options[2]["has_followup"] is True
assert question.options[2]["followup_label"] == "Please specify"
Testing API Validation
Test that the API properly validates inputs and returns helpful error messages:
def test_validation_invalid_question_type(self, client, setup_basic_survey):
"""Test that invalid question types return 400 error."""
user, survey, headers = setup_basic_survey
url = f"/api/surveys/{survey.id}/seed/"
payload = [{"type": "invalid_type", "text": "Test"}]
response = client.post(url, data=json.dumps(payload),
content_type="application/json", **headers)
assert response.status_code == 400
data = response.json()
assert "errors" in data
assert "valid_types" in data
# Should list all valid types
assert "text" in data["valid_types"]
assert "mc_single" in data["valid_types"]
Testing Warning vs Error Behavior
The API distinguishes between critical errors (which prevent creation) and warnings (which allow creation but notify the user):
def test_validation_warning_missing_text(self, client, setup_basic_survey):
"""Test that missing text returns warning but succeeds."""
user, survey, headers = setup_basic_survey
url = f"/api/surveys/{survey.id}/seed/"
payload = [{"type": "text"}] # Missing text field
response = client.post(url, data=json.dumps(payload),
content_type="application/json", **headers)
# Should succeed with warning
assert response.status_code == 200
data = response.json()
assert "warnings" in data
# Question created with default text
question = SurveyQuestion.objects.get(survey=survey)
assert question.text == "Untitled"
Testing Question Groups
def test_seed_with_multiple_question_groups(self, client, setup_basic_survey):
"""Test seeding questions with group assignments."""
user, survey, headers = setup_basic_survey
url = f"/api/surveys/{survey.id}/seed/"
payload = [
{
"type": "text",
"text": "Name",
"group": "Demographics"
},
{
"type": "text",
"text": "Age",
"group": "Demographics"
},
{
"type": "text",
"text": "Feedback",
"group": "Comments"
}
]
response = client.post(url, data=json.dumps(payload),
content_type="application/json", **headers)
assert response.status_code == 200
# Should create 2 groups
groups = QuestionGroup.objects.filter(owner=user)
assert groups.count() == 2
# Questions assigned to correct groups
demo_group = groups.get(name="Demographics")
assert SurveyQuestion.objects.filter(group=demo_group).count() == 2
Testing Response Formats
Always verify: 1. HTTP status code 2. Response structure 3. Data types 4. Required fields
def test_response_format(self, client, setup_basic_survey):
"""Verify API response structure."""
user, survey, headers = setup_basic_survey
url = f"/api/surveys/{survey.id}/seed/"
payload = [{"type": "text", "text": "Test"}]
response = client.post(url, data=json.dumps(payload),
content_type="application/json", **headers)
assert response.status_code == 200
data = response.json()
# Check structure
assert isinstance(data, dict)
assert "questions" in data
assert isinstance(data["questions"], list)
# Check question data
question_data = data["questions"][0]
assert "id" in question_data
assert "text" in question_data
assert "type" in question_data
assert isinstance(question_data["id"], int)
Edge Cases and Error Handling
Test boundary conditions and error scenarios:
def test_seed_empty_payload(self, client, setup_basic_survey):
"""Test seeding with empty payload."""
user, survey, headers = setup_basic_survey
url = f"/api/surveys/{survey.id}/seed/"
response = client.post(url, data=json.dumps([]),
content_type="application/json", **headers)
# Should handle gracefully
assert response.status_code in [200, 400]
assert SurveyQuestion.objects.filter(survey=survey).count() == 0
def test_seed_malformed_json(self, client, setup_basic_survey):
"""Test handling of malformed JSON."""
user, survey, headers = setup_basic_survey
url = f"/api/surveys/{survey.id}/seed/"
response = client.post(url, data="{invalid json}",
content_type="application/json", **headers)
assert response.status_code == 400
Best Practices
1. Use Fixtures for Setup
Create reusable fixtures for common setup:
@pytest.fixture
def setup_basic_survey(self, client):
"""Create user, survey, and auth headers."""
user = User.objects.create_user(username="testuser", password="testpass")
survey = Survey.objects.create(owner=user, name="Test", slug="test")
resp = client.post("/api/token",
data=json.dumps({"username": "testuser", "password": "testpass"}),
content_type="application/json")
token = resp.json()["access"]
headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
return user, survey, headers
2. Test One Thing Per Test
Keep tests focused:
# Good - tests one specific behavior
def test_required_question_validation(self, client):
"""Test that required field is properly validated."""
# ... test only required field validation
# Avoid - tests multiple behaviors
def test_everything(self, client):
"""Test question creation and editing and deletion."""
# ... too much in one test
3. Use Descriptive Test Names
# Good
def test_seed_mc_single_with_followup_text(self, client):
# Avoid
def test_mc(self, client):
4. Test Both Success and Failure Paths
def test_create_question_success(self, client):
"""Test successful question creation."""
# ... test happy path
def test_create_question_invalid_type(self, client):
"""Test question creation with invalid type fails."""
# ... test error case
5. Assert Database State
Don't just check the API response - verify the database:
response = client.post(url, data=json.dumps(payload), **headers)
assert response.status_code == 200
# Also verify database
question = SurveyQuestion.objects.get(survey=survey)
assert question.text == "Expected text"
assert question.type == SurveyQuestion.Types.TEXT
6. Clean Test Data
Use @pytest.mark.django_db to ensure database cleanup:
@pytest.mark.django_db
class TestMyAPI:
"""Tests with automatic database cleanup."""
def test_something(self, client):
# Database changes rolled back after test
pass
Common Patterns
Testing All Valid Values
def test_validation_all_valid_types_accepted(self, client, setup_basic_survey):
"""Test that all valid question types are accepted."""
user, survey, headers = setup_basic_survey
url = f"/api/surveys/{survey.id}/seed/"
valid_types = ["text", "mc_single", "mc_multi", "dropdown",
"yesno", "likert", "orderable", "image",
"template_patient", "template_professional"]
for qtype in valid_types:
payload = [{"type": qtype, "text": f"Test {qtype}"}]
response = client.post(url, data=json.dumps(payload),
content_type="application/json", **headers)
assert response.status_code == 200, f"Failed for type: {qtype}"
# Clean up for next iteration
SurveyQuestion.objects.filter(survey=survey).delete()
Testing Multiple Items
def test_seed_multiple_questions(self, client, setup_basic_survey):
"""Test seeding multiple questions in one request."""
user, survey, headers = setup_basic_survey
url = f"/api/surveys/{survey.id}/seed/"
payload = [
{"type": "text", "text": "Question 1"},
{"type": "mc_single", "text": "Question 2", "options": ["A", "B"]},
{"type": "yesno", "text": "Question 3"}
]
response = client.post(url, data=json.dumps(payload),
content_type="application/json", **headers)
assert response.status_code == 200
assert SurveyQuestion.objects.filter(survey=survey).count() == 3
Troubleshooting
Test Fails with 401 Unauthorized
- Check JWT token is correctly obtained and included in headers
- Verify user exists and credentials are correct
- Ensure token hasn't expired (use fresh token for each test)
Test Fails with 403 Forbidden
- Verify user has required permissions
- Check survey ownership
- See permission tests for examples
JSON Decode Errors
- Ensure
content_type="application/json"is set - Use
json.dumps()for payload - Check response has JSON content before calling
.json()
Reference Tests
For comprehensive examples, see:
- tests/test_api_questions_and_groups.py - 35 tests covering questions/groups API
- tests/test_api_permissions.py - Permission and access control patterns
- tests/test_user_api.py - User management API patterns
- /checktick_app/api/tests/test_publish_and_metrics_api.py - Publishing and metrics patterns