Testing in Django

Testing in Django

Testing is crucial in the software development lifecycle, ensuring that applications meet the required quality standards and function as expected. It systematically evaluates software components, modules, or systems to identify errors, bugs, or deviations from the desired behaviour.

Testing helps mitigate risk, enhance reliability, and improve the overall user experience.

The primary goal of testing is to uncover defects or discrepancies between the expected and actual results of a software system.

By executing a series of predefined test cases, developers and quality assurance professionals can verify the correctness, completeness, and robustness of the software. Testing also helps identify performance bottlenecks, security vulnerabilities, and compatibility issues.

Types of Tests

There are various types of tests that are commonly performed during the software development process, including:

  • Unit tests: These tests focus on testing individual units or components in isolation.

  • Integration tests: These tests verify the interaction and integration between multiple units.

  • System tests: These tests verify the overall functionality of the software system.

  • Acceptance tests: These tests are performed by users or customers to verify that the software meets their requirements.

Testing can be performed manually or using automated testing frameworks and tools. Automated testing is preferred in modern software development due to its efficiency, repeatability, and scalability.

It allows for the creation of test suites that can be executed automatically, enabling developers to catch issues early and streamline the development process.

Testing is not a standalone activity in software development but rather an integral part of the development workflow. It should be performed continuously throughout the development cycle, starting from the early stages of requirements gathering and design, through implementation, and up to deployment and maintenance.

This iterative testing approach ensures that defects are detected and fixed promptly, reducing the cost and effort associated with rework and bug fixing.

In this article, we will be looking at testing with Django. Lets uncover what Django is.

What is Django?

Django is an open-source web framework in Python that follows the modelviewstemplate (MVT) architectural pattern. Django provides a comprehensive set of tools, libraries, and features that enable developers to create scalable, secure, and maintainable web applications.

It is maintained by the Django Software Foundation. Some of the essential features in Django are a Ready-to-use admin interface/panel, support for Internationalization and Localization, Template Engine, Object-Relational Mapping (ORM), and Testing framework, among others.

Developers widely use Django due to its versatility, extensibility, and strong community support. It is a popular choice for web development in Python.

Prerequisites

To follow through in this article, the following is needed

  • Knowledge of Python and Django syntax.

  • A bit of knowledge of testing.

Testing with Django

To test in Django, the library has to first be downloaded. To download Django, we use pip. In this article, we will use pip to manage our downloads on our local computer. To do this, we need to run the command below in our terminal:

pip install django

After installing Django, we create a project with the django-admin keyword

django-admin startproject progy .

This creates the base boilerplate for our project. We then create an app. An app is a self-contained module or component that encapsulates a specific functionality or feature of a web application. We will call our app account.

django-admin startapp account

It would look similar to this after the creation.

Screenshot of the Django app in VScode

Next, we include our app into the INSTALLED APPS in the [settings.py]

    INSTALLED_APPS = [
        'django.contrib.admin',
        'django.contrib.auth',
        'django.contrib.contenttypes',
        'django.contrib.sessions',
        'django.contrib.messages',
        'django.contrib.staticfiles',

        "account", # our app
    ]

And in our base urls.py , we include the apps url file. Note: Create a urls.py file to avoid errors.

from django.contrib import admin
from django.urls import path, include

url_patterns = [
    path('admin/', admin.site.urls),
    path('', include('account.urls')),
]

Writing Unit Tests

Writing unit tests is a fundamental aspect of software development, and Django provides a comprehensive testing framework to facilitate the process. This is the most common form of test in Django. We will be looking at testing the views. Our views.py code is as follows:

from django.http import HttpResponse

    def hello_world(request):
        """A simple view that returns a string "Hello, world!""""
        return HttpResponse("Hello, world!")

    def add_numbers(request):
        """A view that returns the sum of two numbers."""
        if request.method == 'POST':
            num1 = int(request.POST.get('num1'))
            num2 = int(request.POST.get('num2'))
            sum = num1 + num2
            return HttpResponse(f"The sum of {num1} and {num2} is {sum}.")
        else:
            return HttpResponse("Please submit the form to add two numbers.")

In Django, unit tests are typically organized into test case classes. We create a new class that inherits from Django's django.test.TestCase in our tests.py. The unit cases will be based on the two functions we have above, hello_world and add_numbers respectively. Before that, let us update our apps urls.py

from django.urls import path
from . import views

app_name = 'accounts'

urlpatterns = [
    path('hello/', views.hello_world, name='hello_world'),
    path('add/', views.add_numbers, name='add_numbers'),
]

The unit tests for these functions will look like:

from django.test import TestCase
    from django.urls import reverse

    class HelloWorldViewTest(TestCase):
        def test_hello_world(self):
            url = reverse('accounts:hello_world')
            response = self.client.get(url)
            self.assertEqual(response.status_code, 200)
            self.assertEqual(response.content.decode(), "Hello, world!")

    class AddNumbersViewTest(TestCase):
        def test_add_numbers_get(self):
            url = reverse('accounts:add_numbers')
            response = self.client.get(url)
            self.assertEqual(response.status_code, 200)
            self.assertContains(response, "Please submit the form to add two numbers.")

        def test_add_numbers_post(self):
            url = reverse('accounts:add_numbers')
            data = {'num1': '5', 'num2': '3'}
            response = self.client.post(url, data)
            self.assertEqual(response.status_code, 200)
            self.assertContains(response, "The sum of 5 and 3 is 8.")

To ensure our tests run and to see the result, we use the command python manage.py test and this gives us:

Screenshot of the test being run.

This process creates a test database and destroys it afterward.

Writing Integration Tests

In the context of web development with Django, integration tests involve testing the collaboration between various parts of the application, such as views, models, templates, and the database.

For our integration test, we would be creating a model in our models.py for testing.

 from django.db import models

    class Name(models.Model):
        """A name model."""
        name = models.CharField(max_length=100)
        description = models.TextField()

        def __str__(self):
            return self.name

And our views.py will return the list of names we have in our database

def name_list(request):
    """A view that returns a list of names."""
    names = Name.objects.all()
    return render(request, 'account/name_list.html', {'names': names})

The urls.py

from django.urls import path
from . import views

app_name = 'accounts'

urlpatterns = [
    path('names/', views.name_list, name='name_list'),
]

And our integration test in the tests.py will be:

from django.test import TestCase, Client
    from django.urls import reverse
    from .models import Name

    class NameIntegrationTest(TestCase):
        def setUp(self):
            self.client = Client()
            self.url = reverse('accounts:name_list')

        def test_name_list_integration(self):
            # Create test data
            Name.objects.create(name='John', description='John Doe')
            Name.objects.create(name='Jane', description='Jane Smith')

            # Send a GET request to the URL
            response = self.client.get(self.url)

            # Assert the response status code and content
            self.assertEqual(response.status_code, 200)
            self.assertContains(response, 'John')
            self.assertContains(response, 'Jane')

It's important to note that integration tests complement unit tests, which focus on testing individual units or components in isolation. Both types of tests are valuable for ensuring the overall quality and reliability of a software application.

Writing Functional Tests

Functional tests, also known as end-to-end tests or acceptance tests, are software testing that verifies the functionality of an application from the user's perspective. In Django, functional tests simulate user interactions with the application and validate the expected behaviour. Using our existing example from the models.py , views.py, and urls.py , we create a functional test in our tests.py with Selenium in this article.

Selenium is an open-source framework commonly used for automating web browsers. It provides a set of tools and libraries for interacting with web browsers and automating browser actions, such as clicking buttons, filling out forms, and navigating through web pages. It also supports many languages and Python is one of them. Our tests.py becomes

from django.test import LiveServerTestCase
from selenium import webdriver
from .models import Name
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By

class NameFunctionalTest(LiveServerTestCase):
    def setUp(self):
        self.selenium = webdriver.Chrome()
        super().setUp()

    def tearDown(self):
        self.selenium.quit()
        super().tearDown()

    def test_name_list_functional(self):
        # Create test data
        Name.objects.create(name='John', description='John Doe')
        Name.objects.create(name='Jane', description='Jane Smith')

        # Simulate user interactions using Selenium
        self.selenium.get(self.live_server_url + '/names/')
        self.assertIn('Name List', self.selenium.title)
        names =self.selenium.find_elements(By.TAG_NAME, 'li')
        self.assertEqual(len(names), 2)
        self.assertEqual(names[0].text, 'John - John Doe')
        self.assertEqual(names[1].text, 'Jane - Jane Smith')

        # Simulate the page returns a 200
        self.assertEqual(self.selenium.title, 'Name List')

After the test command is being run, a Chrome browser will quickly pop up and perform the test.

Test Coverage and Reporting

Test coverage is a measure of the extent to which our code is being tested by our test suite. Test coverage is essential for ensuring the quality and reliability of our software.

Django has various tools for measuring test coverage and generating reports. One popular tool is coverage.py, a Python library that tracks which parts of the code are executed during the test run.

To use coverage.py for test coverage and reporting in Django, let us go through the following steps

1. Install coverage with pip by running the following command

pip install coverage

2. Run tests with coverage by using the coverage command

coverage run manage.py test

3. After running the tests, we can generate the coverage report using the coverage report command. This command shows the coverage summary in the terminal:

coverage report
coverage html # to generate an HTML report that is well detailed

To customize the coverage, a .coveragerc configuration file can be created at the root directory of our Django project. For example, the coverage of my test for my code was:

Screenshot of the .coveragc directory

Test Fixtures and Mocking

Test fixtures and mocking are important techniques in software testing that help create controlled environments and isolate dependencies to ensure reliable and predictable test results.

Text Fixtures

Test fixtures are a set of predefined data, configuration, or state that is set up before running tests. They help create a consistent and known starting point for tests, allowing us to reproduce specific conditions and behaviours. In Django, we can use fixtures to load data into our test database.

To create a fixture file, we first create a JSON file that defines the test data we want to use. For example, lets create a sample.json file with the following entries:

[
  {
    "name": "Herlet Skim",
    "description": "A random person that exists out of nowhere but has a pretty interesting life."
  },
  {
    "name": "John Doe",
    "description": "A fictional character who is often used as a placeholder name."
  },
  {
    "name": "Jane Doe",
    "description": "A fictional character who is often used as a placeholder name for a woman."
  },
  {
    "name": "Sherlock Holmes",
    "description": "A fictional detective created by Sir Arthur Conan Doyle."
  },
  {
    "name": "Dr. Watson",
    "description": "A fictional doctor and the partner of Sherlock Holmes."
  }
]

After having a sample.json file, we can load the fixtures using the fixtures attribute of the test case. By specifying the fixture file in the fixtures attribute, Django will load the test data defined in the fixture before running the test method.

from django.test import TestCase

class MyTestCase(TestCase):
    fixtures = ['sample.json']

    def test_something(self):
        # Test logic here

Mocking

Mocking, on the other hand, is a process of replacing real objects or functions with test-specific versions that replicate their behaviour. In Python/Django, the unittest.mock module provides powerful mocking capabilities.

To get started with mocking, we import the mock module

from unittest.mock import Mock

Using our previous data from the models.py , views.py, and urls.py , we create a new tests.py for mocking

from django.test import TestCase, RequestFactory
from unittest.mock import Mock, patch
from .models import Name
from .views import name_list

class NameListViewTest(TestCase):
    def setUp(self):
        self.factory = RequestFactory()

    def test_name_list_view(self):
        # Create some sample Name objects
        Name.objects.create(name='John Doe', description='Description 1')
        Name.objects.create(name='Jane Smith', description='Description 2')

        # Create a mock request object
        request = self.factory.get('/names/')

        # Create a mock queryset for the Name objects
        mock_queryset = Mock(spec=Name.objects.all())
        mock_queryset.return_value = [
            Mock(name='John Doe', description='Description 1'),
            Mock(name='Jane Smith', description='Description 2')
        ]

        # Patch the Name.objects.all() method to return the mock queryset
        with patch('account.views.Name.objects.all', mock_queryset):
            # Call the name_list view
            response = name_list(request)

        # Assert that the response has the expected status code
        self.assertEqual(response.status_code, 200)

        # Assert that the response contains the expected data
        self.assertContains(response, 'John Doe')
        self.assertContains(response, 'Jane Smith')

Testing Best Practices

When it comes to testing, following best practices can significantly improve the effectiveness and efficiency of our testing process. Some of the best testing practices to consider are:

1. Start with a testing strategy: Define a clear testing strategy that outlines the goals, objectives, and approach for testing software. Identify the types of tests to perform (unit tests, integration tests, etc.), determine the scope of testing, and establish guidelines for test coverage.

2. Write testable code: Design code to make it easy to test. Follow principles like SOLID and DRY to write modular, decoupled, and reusable code. Separate business logic from presentation and external dependencies, which allows for easier unit testing.

3. Follow the AAA pattern: When writing unit tests, structure them using the Arrange-Act-Assert (AAA) pattern. Arrange the test data, set up the necessary preconditions, act on the tested code, and assert the expected results or behaviours.

4. Test edge cases and boundary conditions: Ensure that the tests cover a wide range of scenarios, including edge cases and boundary conditions. Test inputs at their minimum and maximum values, empty or null values, and any special cases that could affect the behaviour of the code.

5. Monitor code coverage: Measure and monitor code coverage to ensure that tests adequately cover the codebase. Aim for high code coverage to increase confidence in the reliability and correctness of the software.

6. Maintain and update tests: Keep tests up to date with changes in the codebase. As changes are made to the application, review and update tests accordingly. Outdated tests can give false positives or false negatives, leading to unreliable results.

Test Automation and Continuous Integration

Test automation and continuous integration are key practices in software development that help improve efficiency, quality, and reliability. Let's explore these two concepts in more detail.

Test Automation

This involves using tools and scripts to automate the execution of tests, reducing manual effort and enabling faster and more frequent testing. It involves writing code to automate the setup, execution, and validation of test cases. Some of the benefits of test automation include:

1. Improved Efficiency: Automated tests can be executed much faster compared to manual tests, enabling faster feedback on the quality of the software.

2. Increased Test Coverage: With test automation, it becomes easier to achieve high test coverage by running a larger number of tests within a shorter timeframe.

3. Integration with CI/CD: Automated tests can be seamlessly integrated into the CI/CD pipeline, enabling continuous testing and faster delivery of software.

4. Consistency: Automated tests ensure consistent test execution and eliminate human errors that may occur during manual testing.

Popular test automation frameworks and tools for Django include unittest, pytest, Selenium, WebDriver for browser automation, and Django's built-in test framework.

Continuous Integration (CI)

Continuous Integration is a development practice where developers frequently merge their code changes into a shared repository. The CI process involves automatically building and testing the software with each code commit. Some of the key aspects of CI are:

1. Automated Builds: CI systems automatically build the software upon each code commit, ensuring that the codebase remains in a consistent and functional state.

2. Automated Testing: Automated tests, including unit tests, integration tests, and even UI tests, are executed as part of the CI process. This ensures that code changes do not introduce regressions or break existing functionality. 3. Integration with Version Control: CI systems are integrated with version control systems like Git, allowing them to monitor code changes and trigger the build and test process automatically. 4. Deployment Readiness: CI helps ensure that the software is always in a deployable state. If the build and tests pass successfully, the software is ready for deployment.

Popular CI tools include Jenkins, Travis CI, CircleCI, and GitLab CI/CD.

By combining test automation and continuous integration, we can establish a robust and efficient software development process. Automated tests also provide quick feedback on code changes, detect issues early, and improve overall software quality.

Conclusion

Testing is an important aspect of the software development cycle as it helps for quality code, reliability, and correctness.

By adopting a comprehensive testing approach in our Django projects, we can improve our applications' stability, maintainability, and overall quality.

It helps developers deliver products that meet user expectations, comply with specifications, and withstand real-world usage scenarios.

By incorporating testing practices into their development workflows, software teams can achieve higher quality assurance and customer satisfaction levels.