Easy User Interaction testing with Django-Webtest
If you’re building a web application, you'll want to test what a user sees and does (or might do). Webtest allows you to interact with the HTML in the way a user might. It fits between unit tests and more complex user interaction testing solutions. This blog post is intended as an introduction, and shows how to get started.
First, we need something to test on. I've followed Django's excellent tutorial to create a project.
I've used different models, however: my "goal" with this tutorial was to create a project where people can predict the winner of a tournament (for example, the soccer World Championship), to create a "pool".
So instead of Poll, Question, and Choice, you'll find models for Tournament, Team, and Prediction. The complete code of that project, which kind of follows the Django tutorial, can be found on Github.
The result looks like this:

Please bear in mind that making it look good was not the goal of this project.
The form to add a Prediction looks like this:

Now that our project has been introduced, we'll start testing its models and views.
Testing a model and view with Django
To introduce Django's testing framework, let's test a method on the Prediction model. If you already are familiar with Django testing, just skip to the next chapter, "Testing a form submit with Django".
Given our Prediction model:
class Prediction(models.Model):
name = models.CharField(max_length=200)
tournament = models.ForeignKey(Tournament, on_delete=models.CASCADE)
winning_team = models.ForeignKey(Team, on_delete=models.CASCADE)
def __str__(self):
return f"{self.name} predicts {self.winning_team} will win {self.tournament}"
We can test the string representation as follows:
class PredictionModelTests(TestCase):
def test_string_representation(self):
"""
The string representation should include name, tournament and winning team.
"""
tournament = Tournament(name="Soccer Worlds 2022")
team = Team(name="Netherlands", tournament=tournament)
prediction = Prediction(name="Joe", tournament=tournament, winning_team=team)
self.assertIn("Joe", str(prediction))
self.assertIn("Netherlands", str(prediction))
self.assertIn("Soccer Worlds 2022", str(prediction))
Given this view class to list predictions:
class PredictionListView(generic.ListView):
template_name = "predictions/prediction_list.html"
context_object_name = "prediction_list"
def get_queryset(self):
tournament = Tournament.objects.last()
if not tournament:
raise Http404()
return tournament.prediction_set.all()
We can test that the view shows the latest Tournament's Predictions, if present:
class PredictionIndexViewTests(TestCase):
def test_no_tournaments(self):
"""
If there are no tournaments, we should get a 404 Not Found.
"""
response = self.client.get(reverse("predictions:list"))
self.assertEqual(response.status_code, 404)
def test_tournament_with_predictions(self):
"""
If there is a tournaments, we should show its predictions.
"""
tournament = Tournament.objects.create(name="Soccer Worlds 2022")
team = Team.objects.create(name="Netherlands", tournament=tournament)
prediction = Prediction.objects.create(
name="Joe", tournament=tournament, winning_team=team
)
response = self.client.get(reverse("predictions:list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, str(prediction))
Testing a form submit with Django
The topic of testing a form submit is not covered in the Django tutorial, but it's a very interesting one.
A blog post by Adam Johnson is a very complete summary of what can be done with plain Django testing methods.
Note that in this example, he uses Book and Author models.
He suggests two kinds of tests, Integration Tests and Unit Tests:
Integration tests
In the integraton test, we totally bypass the form itself, and just do a POST request to our test client, with the data that we expect the form to contain.
His example uses different models, but you'll get the idea:
class AddBookFormTests(TestCase):
def test_title_starting_lowercase(self):
response = self.client.post("/books/add/", data={"title": "a lowercase title"})
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(
response, "Should start with an uppercase letter", html=True
)
Unit tests
In the unit test, we test the Django form itself. Usually Django applications will always subclass a Django Form.
class AddBookFormTests(TestCase):
def test_title_starting_lowercase(self):
form = AddBookForm(data={"title": "a lowercase title"})
self.assertEqual(
form.errors["title"], ["Should start with an uppercase letter"]
)
Discussion of these approaches
These two ways of testing together cover the submitting of a form. That's perfectly fine, but:
- The "integration test" is detached from reality, in the sense that we craft a POST request containing form data without actually using the form itself.
- The "unit test" only tests the Django form and its validation methods. The form integration can still be misconfigured, for example when using a custom template to render the form.
Wouldn't it be nice to test these things together?
Enter Django-Webtest
This is where Webtest comes into the picture. Webtest looks at the HTML, and can parse it. Without knowing anything about the underlying framework, it can see which fields a form contains. It can also change a form value and submit it. Under the hood, it uses WebOb, Waitress and BeautifulSoup.
As advertised, Webtest will work with any WSGI application. There's also a Flask implementation. We're using django-webtest.
Form submit with webtest
With webtest, we can load the page to add a Prediction. The response is a Python object with a form
attribute:
from django_webtest import WebTest
class PredictionWebTests(WebTest):
def test_add_prediction(self):
"""
We should be able to cast a prediction.
"""
tournament = Tournament.objects.create(name="Soccer Worlds 2022")
team = Team.objects.create(name="Netherlands", tournament=tournament)
response = self.app.get(reverse("predictions:add_prediction"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, f"Who will win {tournament}?")
# Play around with Webtest's form API
form = response.form
field = form["team"]
self.assertEqual(field.options, [(str(team.pk), False, None)])
self.assertEqual(field.value, None)
This makes selecting a value and submitting the form very easy:
# Select a team and submit
form["team"] = team.pk
form["name"] = "John Doe"
post_submit_page = form.submit().follow()
self.assertEqual(
post_submit_page.request.path,
reverse("predictions:list"),
"We should be redirected to the predictions list",
)
self.assertEqual(
Prediction.objects.count(), 1, "A Prediction should have been created"
)
prediction = Prediction.objects.get()
self.assertEqual(prediction.winning_team, team)
self.assertEqual(prediction.name, "John Doe")
No more self.assertEquals or self.assertContains
The syntax self.assertEquals
and self.assertContains
always makes me cry. With webtest, you can write it as follows:
class PredictionWebTests(WebTest):
def test_cast_prediction(self):
# ...
assert response.status_code == 200
assert f"Who will win {tournament}?" in response
Search in HTML with BeautifulSoup
response.html
is a BeautifulSoup object, whose HTML can be easily searched, for example with a CSS selector:
assert response.html.select("ul.errorlist")
Martijn Jacobs wrote a nice blog post about why you would want to do this.
Show in browser
response.showbrowser()
opens a browser window showing the entire page (unstyled). This makes it easy to troubleshoot failing tests.
Note that in the test below, we use the `pytest` test syntax, which webtest also supports.
def test_create_prediction_pytest(django_app, admin_user):
tournament = Tournament.objects.create(name="Soccer Worlds 2022")
team = Team.objects.create(name="Netherlands", tournament=tournament)
assert Prediction.objects.exists() is False
changelist_url = reverse("admin:predictions_prediction_changelist")
predictions_changelist_page = django_app.get(changelist_url, user=admin_user)
add_prediction_page = predictions_changelist_page.click("Add prediction")
assert tournament.name in add_prediction_page
assert team.name in add_prediction_page
add_prediction_page.showbrowser()
This gives us:

Summary
Webtest makes testing simple user interactions easier. Even if it's just to submit a form, or check that a user sees something in their browser, nothing beats webtest. It's dead simple.
Webtest tests are slower than unit tests, because they retrieve the form and submit it. If you have a lot of tests, unit tests (so testing the underlying framework, like the Django Form class) will probably be quicker. Still, it makes sense to have at least one test where you test the entire form.
For functionality that depends on Javascript, Webtest won't work. If you use React or Vue, you're probably better off using these frameworks' testing frameworks. For more comprehensive User Interaction tests, you'll probably like Selenium or Playwright, these are fine solutions but a bit complex to setup.