Testing Django with Cypress, how nice!
When I discovered Cypress in 2017 my life as a developer changed. I was not afraid to write functional tests anymore, and since then I applied this tool to any web framework I worked with.
In particular, I've been working almost exclusively with Django these days, and even if JavaScript does not fare so well in Python developers circle, when it comes to testing a Django/JavaScript project my tool of choice is always Cypress.
In this post I share a couple of recipes for testing Django with Cypress, with a focus on the authentication flow.
Testing Django login with Cypress
As suggested in the Cypress documentation, you may want to test the authentication flow of a web application no more than once with the UI.
This means you create a single test somewhere, where you check your login page:
describe("Login", () => {
before(() => {
cy.fixture("users.json").as("mockedUsers");
});
it("Can login through the UI", function () {
cy.visit("/login/");
cy.get("input[name='username']").type(this.mockedUsers[0].fields.email);
cy.get("input[name='password']").type("dummy_password");
cy.get("form").submit();
cy.getCookie("sessionid").should("exist");
});
});
As for the fixtures, you can use the same data from Django dumpdata
, aply saved in cypress/fixtures/fixture_name.json
.
Testing Django login without the UI
So far so good for the first login test. What if you now need to test other authenticated sections of the website?
Subsequent tests which require authentication should not use the UI again. What you should do instead is use cy.request()
from Cypress, and preserve session cookies.
In all the other tests requiring authentication you do something like this:
describe("Authenticated sections", () => {
before(() => {
cy.fixture("users.json").as("mockedUsers");
cy.visit("/login/");
cy.get("[name=csrfmiddlewaretoken]")
.should("exist")
.should("have.attr", "value")
.as("csrfToken");
cy.get("@csrfToken").then((token) => {
cy.request({
method: "POST",
url: "/login/",
form: true,
body: {
username: "juliana.crain@dev.io",
password: "dummy_password",
},
headers: {
"X-CSRFTOKEN": token,
},
});
});
cy.getCookie("sessionid").should("exist");
cy.getCookie("csrftoken").should("exist");
});
beforeEach(() => {
Cypress.Cookies.preserveOnce("sessionid", "csrftoken");
});
it("should do something", () => {
// your test here
// it's authenticated from now on!
});
it("should do something", () => {
// your test here
// it's authenticated from now on!
});
});
What's happening here?
- we save the CSRF Django token with
.as("csrfToken")
. - we use
cy.request()
to make aPOST
request of typex-www-form-urlencoded
by passing also the token in the headers as"X-CSRFTOKEN": token
This is way faster than using the UI over and over again to login.
Next up, we preserve cookies on each subsequent test with:
beforeEach(() => {
Cypress.Cookies.preserveOnce("sessionid", "csrftoken");
});
All the tests now are authenticated and will send the cookies on each request to the backend.
UPDATE: starting from Cypress 8.2.0, we can now use cy.session()
to cache and restore cookies as described in the documentation.
Django testserver meets Cypress
Django has a testserver
command which you can use to start an ephemeral test database:
python manage.py testserver cypress/fixtures/users.json --noinput
The fixtures are obtained from dumpdata
, and since they're JSON, they can be used by Cypress as well. You can load many fixture files at once.
Mocking the backend
In the "past" (which is 2 or 3 months in the JavaScript land) Cypress used an experimental Fetch polyfill for mocking Fetch calls.
They now came up with a new API, called intercept()
. If your Django templates make XHR requests, you can avoid touching the backend altogether by stubbing the response. Here's an example:
it("Can close tickets", () => {
cy.get(".ticket__state")
.should("have.class", "bg-yellow-200")
.and("have.text", "Opened");
cy.intercept("POST", "tickets", {
statusCode: 200,
ok: true,
});
cy.get("#close-ticket").click();
cy.get(".ticket__state")
.should("have.class", "bg-blue-200")
.and("have.text", "Closed");
});