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. Let’s uncover what Django is.
What is Django?
Django is an open-source web framework in Python that follows the model–views–template (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.
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 app’s 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 app’s 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:
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:
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, let’s 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.