Basic
Implementing createUser()
If we want to test authentication, we need to have some user credentials to authenticate with. This is where the
createUser() utility function comes in as it will create a test user in the database and expose its credentials in the test.export async function createUser() {
const userInfo = generateUserInfo()
const password = 'supersecret'
const user = await prisma.user.create({
data: {
...userInfo,
password: { create: { hash: await getPasswordHash(password) } },
},
})
return {
async [Symbol.asyncDispose]() {
await prisma.user.deleteMany({
where: { id: user.id },
})
},
...user,
password,
}
}
We are relying on the existing
generateUserInfo() function to generate a username and email, providing a hard-coded password, and using the Prisma client to create the actual user record in the database.Notice that apart from
user and password our utility also returns a Symbol.asyncDispose function. That function marks the entire returned object as disposable, which we can leverage to have the cleanup (removing the test user) tied to when this function (and its surrounding scope) gets garbage collected.Fixtures vs utilities
Okay, you are probably wondering: Why isn't
createUser() a fixture instead?That's a great question. The truth is, it can be a fixture, and nothing is stopping it from being one. As we've previously established, Playwright tests run in Node.js, which means our fixtures can tap into Node.js APIs and third-party packages relying on those.
But I didn't make
createUser() into a Playwright fixture for a reason. See, when it comes to deciding between custom fixtures and plain functions, I follow a simple logic:- If my utility function depends on the browser APIs or the test context, I make it a fixture;
- If my utility function has nothing to do with the browser or Playwright, I keep it a separate function.
Think back on our
navigate() fixture. Its purpose is type-safety but its functionality is tightly coupled with the actual page navigation. Playwright already has an API for thatβpage.goto(). It would be rather cumbersome for me to trigger a page navigation from outside Playwright's context. So I make navigate() a fixture because it directly benefits from easier access to Playwright APIs.createUser() though, not so much. It's a data seeding utility at its heart and has no dependency on Playwright or the browser whatsoever. As such, it doesn't belong in the fixtures.Testing authentication
Equipped with our fixtures and utilities, we can now write the first test case for the authentication flow.
import { createUser } from '#tests/db-utils.ts'
import { test, expect } from '#tests/test-extend.ts'
test('authenticates using a email and password', async ({ navigate, page }) => {
await using user = await createUser()
await navigate('/login')
await page.getByLabel('Username').fill(user.username)
await page.getByLabel('Password').fill(user.password)
await page.getByRole('button', { name: 'Log in' }).click()
await expect(page.getByRole('link', { name: user.name! })).toBeVisible()
})
Testing authentication is no different from testing anything else on the page. We prepare our application (by creating a test user), visit the login page the user would visit, interact with it in the same manner, and expect UI elements confirming a successful (or failed) authentication that the user would expect.
Testing authentication failures
Testing failure flows of any kind ties our test to the nature of that failure. The simplest way to illustrate that is to add a test case for a failed authentication flow due to the missing/invalid user credentials:
test('displays an error message when authenticating with invalid credentials', async ({
navigate,
page,
}) => {
await navigate('/login')
await page.getByLabel('Username').fill('non_existing_user')
await page.getByLabel('Password').fill('non_existing_password')
await page.getByRole('button', { name: 'Log in' }).click()
await expect(
page.getByRole('alert').getByText('Invalid username or password'),
).toBeVisible()
})
From the server's perspective, there is no distinction between missing or invalid credentials, so all we have to do to set up our application for this particular scenario is... nothing. We are not creating any test users because we don't need them.
Running the tests
npm run test:e2e