Testing

Testing in Django

Testing is essential for building reliable, maintainable Django applications. It ensures that your code behaves as expected, prevents regressions, and supports refactoring with confidence. Django provides a powerful test framework built on Python's unittest library, plus features for testing views, models, forms, and integration workflows.

The 3 types of tests in Django are:

  • Unit tests - Test a single piece of functionality in isolation (e.g., a model method).
  • Integration tests - Check that multiple components (e.g., model + form + view) work together.
  • Functional tests - Simulate user interactions (e.g., submitting forms in the browser).

When writing tests, start by thinking about the functionality to add. Write small, focused tests for each piece of behaviour.

Test-Driven Development

TDD is a key mindset and methodology to get into when developing any code. In simple terms, you would write a small test to test for 1 functionality and run it. Obviously this will fail as no other code has been written. From here, write the code until the test passes. Then take that code and refactor it to be the simplest it can be whilst still passing the test. Then expand the test or add another for further functionality. This will fail, so write the code until it passes. Then refactor the new code and so on and so on. The core of this philosophy is the knowledge that everything was working "5 seconds ago" and it promotes modular and clear coding.

You will often see TDD described as these 3 stages:

  • Red - Write a test for the desired functionality. It fails (because the feature doesn't exist yet)
  • Green - Implement the code to make the test pass
  • Refactor - Clean up code while keeping tests green

Repeat this process for each feature.

Test Files

When you create a Django app, Django automatically generates a tests.py file. For larger projects, it is best to split tests into multiple files for clarity. Inside an app you can create a tests folder to put all the test files in, for most projects 3 test files will suffice per app:

  • test_views.py
  • test_models.py
  • test_forms.py

At the top of these files you will need to import the correct libraries and methods along with the model or form that is being tested.

from django.test import TestCase  # Provides Django-specific helpers
from .models import Item          # Import models to test from the app models.py file
from .forms import ItemForm       # Import forms to test from the app forms.py file

TestCase extends Python's unittest.TestCase and includes additional Django utilities like an in-memory database and a test client for simulating HTTP requests.

Writing Tests

Each test is:

  • A class that inherits from TestCase
  • Methods that start with test_
  • Define any variables inside the method that you require
  • Assert that specific things happen (if True, the test will pass)
from django.test import TestCase
from .models import Item

class TestDjango(TestCase):
    def test_home_page(self):
        response = self.client.get('/')
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'todo/todo_list.html')

Testing Views

It is worth noting that the tests run with an empty database so if your tests require specific instances they need to be defined in your tests.

setUp

Defining a setUp function inside the test class provides a set of instructions that runs before each of the following test method, so is good for setting common variables.

def setUp(self):
	# Creates Django's test clients allowing simulation of GET/POST requests
    self.client = Client()
	# Resolves the URL into it's actual path e.g. /rooms/avaiable
    self.url = reverse('available_rooms')

Check HTTP Response and Template

The goal of this test is usually to check that the page renders the correct template and loads correctly.

def test_available_rooms_renders_correctly(self):
    '''Test the correct template is used and works'''
    # Simulates a browser GET request to /rooms/available (defined in the setUp)
    response = self.client.get(self.url)
    # Confirms the page loads correctly (code 200)
    self.assertEqual(response.status_code, 200)
    # Confirms the correct template was used
    self.assertTemplateUsed(response, 'rooms/available_rooms.html')

Testing the Context (Read)

It is important that everything that you need in the template is passed in the context of your view. To check that all of these are handled correctly:

def test_available_rooms_context(self):
        '''Test that the correct data is in the context'''
	# Simulate a get response with .get(self.url)
	# Get the test client defined in the setUp
    response = self.client.get(self.url)
	# Asserts to check that the variables are in the context of the get response
    self.assertIn('rooms', response.context)
    self.assertIn('amenities', response.context)
    self.assertIn('booking_form', response.context)
    self.assertIn('booking_form_desktop', response.context)

Testing Valid POST Data

In views that have code in the case of a POST method, you should write a test to check that a valid POST submission works.

def test_available_rooms_valid_data(self):
    '''Test valid data'''
    # Set up data
    valid_data = {
        'mobile-check_in_date': date.today(),
        'mobile-check_out_date': date.today() + timedelta(days=2),
        'mobile-adults': 2,
    }
	# Define the response passing the valid data
    response = self.client.post(self.url, valid_data)
	# Ensure that the response is 200
    self.assertEqual(response.status_code, 200)
	# Check that the view took the input values and output the valid_rooms variable to the context
    self.assertIn('valid_rooms', response.context)

Testing Invalid POST Data

As important as testing that the POST data is valid is to check that the correct responses are given when invalid data is submitted.

def test_available_room_invalid_data(self):
    '''Test invalid data'''
	# Set up the invalid data
    invalid_data = {'mobile-check_in_date': 'invalid-date'}
	# Pass the invalid data to the response
    response = self.client.post(self.url, invalid_data)
    # Check that the page is still loaded as the error is displayed to the user instead of crashing
    self.assertEqual(response.status_code, 200)
    # Confirm that the form is invalid
    self.assertFalse(response.context['booking_form'].is_valid())

Testing Create

class AddRoomViewTest(TestCase):
    '''Test the add room view'''
    def setUp(self):
        '''Create objects to test the add rooms view'''
        # Create sample amenities which is a separate table that the rooms reference
        self.amenity1 = Amenities.objects.create(
            name="bed",
            sanitised_name="Bed",
            icon="bed-icon"
        )
        self.amenity2 = Amenities.objects.create(
            name="wifi",
            sanitised_name="WiFi",
            icon="wifi-icon"
        )
        self.client = Client()

    def test_add_room_valid_data(self):
        """Test adding a room with valid data."""
        # Create valid data
        form_data = {
            'name': "new_room",
            'sanitised_name': "New Room",
            'amenities': [1, 2],
            'description': "A new room description",
            'price': 10.00,
        }
	    # Simulate a post request at the URL resolved by reverse('add_room') with the data
        response = self.client.post(reverse('add_room'), data=form_data)
	    # Check that the data was processed and that the user was redirected (code 302)
        self.assertEqual(response.status_code, 302)
	    # Check that a Room object now exists
        self.assertTrue(Room.objects.exists())

    def test_add_room_invalid_data(self):
        """Test adding a room with invalid data."""
        # Invalid data
        form_data = {
            'name': '',
            'description': '',
            'amenities': [],
            'price': -10.00,
        }
	    # Simulate a post request at the URL resolved by reverse('add_room') with the data
        response = self.client.post(reverse('add_room'), data=form_data)
	    # Check that the response is 200 as the page is reloaded with error messages
        self.assertEqual(response.status_code, 200)
	    # Confirm that the current template was reloaded instead of the user redirected in the case of successful submission
        self.assertTemplateUsed(response, 'rooms/add_room.html')
	    # Ensures there is a form in the context so that errors can be shown
        self.assertIn('form', response.context)
	    # Confirm that the form is invalid
        self.assertFalse(response.context['form'].is_valid())

Testing Edit

class EditRoomViewTest(TestCase):
    '''Test the edit room view'''
    def setUp(self):
        '''Create objects to test the edit rooms view'''

        # Create sample amenities as these are another table referenced by the room
        self.amenity1 = Amenities.objects.create(
            name="bed",
            sanitised_name="Bed",
            icon="bed-icon"
        )

        self.amenity2 = Amenities.objects.create(
            name="wifi",
            sanitised_name="WiFi",
            icon="wifi-icon"
        )

        # Create a sample room to edit
        self.room = Room.objects.create(
            name="test_room",
            sanitised_name="Test Room",
            amenities=[1, 2],
            description="A room for testing",
            image_url=None,
            image=None,
            price=100.00,
            unavailability=["2024-12-20"]
        )

        self.client = Client()
        # Set the url using the id of the above created room
        self.url = reverse('edit_room', args=[self.room.id])

    def test_edit_room_get(self):
        '''Test that the edit page loads correctly'''
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'rooms/edit_room.html')
        self.assertIn('form', response.context)

    def test_edit_room_post_valid_data(self):
        '''Test updating a room with valid data'''
        form_data = {
            'name': "updated_room",
            'sanitised_name': "Updated Room",
            'amenities': [self.amenity1.id],
            'description': "An updated description",
            'price': 150.00,
            'unavailability_input': "2024-12-25,2024-12-26"
        }
        response = self.client.post(self.url, data=form_data)
        # Expect a redirect after successful edit
        self.assertEqual(response.status_code, 302)
        # Reload room from DB and check updated fields
        self.room.refresh_from_db()
        self.assertEqual(self.room.name, "updated_room")
        self.assertEqual(self.room.description, "An updated description")
        self.assertEqual(self.room.price, 150.00)
        self.assertIn("2024-12-25", self.room.unavailability)

    def test_edit_room_post_invalid_data(self):
        '''Test submitting invalid data (e.g., negative price)'''
        form_data = {
            'name': '',
            'sanitised_name': '',
            'amenities': [],
            'description': '',
            'price': -50.00,
            'unavailability_input': "bad-date"
        }
        response = self.client.post(self.url, data=form_data)
        # Expect to stay on page with form errors
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'rooms/edit_room.html')
        self.assertFalse(response.context['form'].is_valid())

Testing Delete

class DeleteRoomViewTest(TestCase):
    '''Test the delete room view'''
    # Create sample amenities
    self.amenity1 = Amenities.objects.create(
        name="bed",
        sanitised_name="Bed",
        icon="bed-icon"
    )

    self.amenity2 = Amenities.objects.create(
        name="wifi",
        sanitised_name="WiFi",
        icon="wifi-icon"
    )

    # Create sample room
    self.room = Room.objects.create(
        name="test_room",
        sanitised_name="Test Room",
        amenities=[1, 2],
        description="A room for testing",
        image_url=None,
        image=None,
        price=100.00,
        unavailability=["2024-12-20"]
    )
    self.client = Client()
    self.url = reverse('delete_room', args=[self.room.id])

    def test_delete_room_get(self):
        '''Test that the delete confirmation page loads correctly'''
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'rooms/delete_room.html')
        self.assertIn('room', response.context)

    def test_delete_room_post(self):
        '''Test that a room is deleted after confirmation'''
        response = self.client.post(self.url)
        # After deletion, expect redirect (e.g., back to room list)
        self.assertEqual(response.status_code, 302)
        # Check that the room no longer exists
        self.assertFalse(Room.objects.filter(id=self.room.id).exists())

Testing Forms

from django.test import TestCase
from .forms import ItemForm

class TestItemForm(TestCase):
    def test_form_exists(self):
        form = ItemForm()
        self.assertIsInstance(form, ItemForm)

    def test_name_field_is_required(self):
        form = ItemForm({'name': ''})
        self.assertFalse(form.is_valid())
        self.assertIn('name', form.errors.keys())
        self.assertEqual(form.errors['name'][0], 'This field is required')

    def test_meta_fields(self):
        self.assertEqual(ItemForm.Meta.fields, ['name', 'done'])

Common Assertions

  • self.assertEqual(x, y) - Check equality.
  • self.assertTrue(x) / self.assertFalse(x)
  • self.assertIn(x, y) - Check that x is in y.
  • self.assertTemplateUsed(response, template)
  • self.assertRedirects(response, url)

Running Tests

Use the following commands in the terminal to run the tests in your tests files.

To run all the tests:

python manage.py test

To run tests from a specific file, append the name of your app and the name of the test file:

python manage.py test app_name.test_views

To run a specific test class from a file:

python manage.py test app_name.test_forms.TestItemForm

To run a specific test method from a file:

python manage.py test app_name.test_forms.TestItemForm.test_name_field_is_required

The output will be displayed as a string of results with each character representing the result of a test such as ".....E....F....." with the output meaning:

  • . - Passed
  • F - Failed
  • E - Error

Coverage

coverage.py is a handy tool to measure the test coverage, that is which lines of your code were run during the test suite, which lines were not run and which files have functionality that is not covered by any of your tests.

This helps you to find untested parts of your code, encourages you to write more complete test suites and improve code reliability and maintainability. This is often used as a metric for continuous development.

To install:

pip install coverage

You can then run the tests through coverage with:

coverage run manage.py test

This will create a report that you can see in your terminal window with:

coverage report

You can also create a HTML report that provides a clear outline of the testing results and coverage with:

coverage html

This creates a htmlcov folder, with a index.html file inside. Open this with live viewer to see the results.