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.
The way you approach creating test users will depend on your application.
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.
πŸ“œ Disposable objects are extremely handy for the test setup as they allow for collocation of the setup and cleanup logic. Do not sleep on them!

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.
Keep in mind that custom fixtures aren't free. By their very design, they are tightly coupled with Playwright, and Playwright dictates the way they are declared and used. This is not a matter of micro-optimization but instead a conscious design decision for your test setup to benefit from.

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.
The most difficult part of arranging an end-to-end test is to understand the pieces involved in the behavior you're testing. Your test setup addresses the application side of it, tackling its complexity, while your test actions/assertions focus on the user-facing side.

Running the tests

npm run test:e2e

Please set the playground first

Loading "Basic"
Loading "Basic"
Login to get access to the exclusive discord channel.