Login Strategies for Cypress Tests
In this article, I will demonstrate different approaches to handling authentication in your Cypress tests when using the NextAuth.js credentials provider. We will cover different methods for balancing test reliability with execution speed.
In our examples, we will use a standard email/password login form, authenticating with a hard-coded test user ([email protected]) configured in the NextAuth.js Credentials Provider.
// /auth.config.js
import CredentialsProvider from "next-auth/providers/credentials";
export const authConfig = {
providers: [
CredentialsProvider({
name: "Credentials",
async authorize(credentials) {
const user = { id: "1", password: "123", email: "[email protected]" };
return credentials.email === user.email &&
credentials.password === user.password
? user
: null;
},
}),
],
pages: { signIn: "/login" },
};
// /app/page.js
"use client";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const router = useRouter();
const handleSubmit = async (e) => {
e.preventDefault();
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
result?.ok && router.refresh();
};
return (
<form onSubmit={handleSubmit}>
<h1>Login</h1>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
);
}
The simplest and often correct approach is UI-based logins, as they accurately reflect real user behavior and are ideal for end-to-end (E2E) testing. However, if your test suite includes many tests that require authentication, UI-based repeated logins can significantly slow down execution. Below, I will cover several optimization techniques when using NextAuth.js with the credentials provider.
// /cypress/e2e/login.cy.js
describe("Login", () => {
const credentials = {
email: "[email protected]",
password: "123",
};
// 1. UI Login
// Pros: Tests real user journey end-to-end
// Cons: Requires full render cycle
it("logs in via UI", () => {
cy.visit("/");
cy.contains("Not logged in");
cy.get('[type="email"]').type(credentials.email);
cy.get('[type="password"]').type(credentials.password);
cy.get("button").contains("Login").click();
cy.contains(`Logged in as ${credentials.email}`).should("be.visible");
});
// 2. Direct API Request Login
// Pros: Fastest method, bypasses UI
// Cons: Doesn't test actual user flow
it("logs in via direct API request", () => {
cy.visit("/");
cy.contains("Not logged in");
cy.request({
method: "GET",
url: `/api/auth/csrf`,
}).then((csrfResponse) => {
return cy.request({
method: "POST",
url: `/api/auth/callback/credentials`,
headers: { "Content-Type": "application/json" },
body: {
...credentials,
csrfToken: csrfResponse.body.csrfToken,
json: "true",
},
});
});
cy.visit("/");
cy.contains(`Logged in as ${credentials.email}`).should("be.visible");
});
// 3. Browser Fetch API Login
// Pros: Tests auth in browser context with real fetch API
// Cons: More complex than cy.request, still skips UI
it("logs in via browser fetch API", () => {
cy.visit("/");
cy.contains("Not logged in");
cy.window().then(async (win) => {
const csrfResponse = await win.fetch("/api/auth/csrf");
const { csrfToken } = await csrfResponse.json();
const loginResponse = await win.fetch("/api/auth/callback/credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...credentials, csrfToken, json: "true" }),
});
return loginResponse.json();
});
cy.visit("/");
cy.contains(`Logged in as ${credentials.email}`).should("be.visible");
});
// 4. Eval-Based Login
// Pros: Demonstrates string injection technique
// Cons: Least readable, potential security concerns
// Use Case: Only needed when testing code evaluation scenarios
it("logs in via eval-based approach", () => {
cy.visit("/");
cy.contains("Not logged in");
cy.window().then((win) => {
return win.eval(`
(() => {
const credentials = ${JSON.stringify(credentials)};
return fetch('/api/auth/csrf')
.then(r => r.json())
.then(({ csrfToken }) =>
fetch('/api/auth/callback/credentials', {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...credentials,
csrfToken,
json: "true"
})
})
)
.then(res => res.json())
})()
`);
});
cy.visit("/");
cy.contains(`Logged in as ${credentials.email}`).should("be.visible");
});
});
For most projects, you'll primarily use either UI login (for full end-to-end tests) or API login (for faster tests). The other methods are just examples. To make testing easier, move your login code into Cypress custom commands - this way you can reuse the same login logic across all your tests, keeping them clean and simple.
// /cypress/support/commands.js
Cypress.Commands.add("signIn", (email, password) => {
cy.request("/api/auth/csrf").then((response) => {
return cy.request({
method: "POST",
url: "/api/auth/callback/credentials",
body: {
email,
password,
csrfToken: response.body.csrfToken,
json: "true",
},
});
});
cy.visit("/");
cy.contains(`Logged in as ${email}`).should("be.visible");
});
Now you can reuse the login command across all your tests
it('logs in via custom command', ()=> {
cy.signIn('[email protected]', '123')
})
Hope this helps! Full code example:
https://github.com/AlekseevArthur/next-auth-cypress