Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions exercises/01.fundamentals/02.problem.running-the-app/README.mdx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Running the app

Alright, so you've got your first end-to-end test written, but it doesn't bring you much value yet (well, because Epic Web isn't the app you want to test!). Now is the time we change that.

## Your task

👨‍💼 It's time for us to start testing _our_ app! But there's a couple of things you have to do before that.
👨‍💼 Now is your turn to start testing _your_ app. But before you do that, you need to make sure Playwright runs it before your tests so they have something to interact with.

🐨 First, let's make sure that Playwright runs your application before it runs your tests. Head to <InlineFile file="./playwright.config.ts">`playwright.config.ts`</InlineFile> and follow the instructions to configure the `webServer` property in Playwright's configuration.
🐨 First, head to <InlineFile file="./playwright.config.ts">`playwright.config.ts`</InlineFile> and follow the instructions to configure the `webServer` property in Playwright's configuration. You will use that option to tell Playwright how to spawn your application.

🐨 Now that `webServer` is configured, it's time to write some tests! Let's start from testing the homepage of the app. I've already got a test file prepared for you at <InlineFile file="./tests/e2e/homepage.test.ts">`tests/e2e/homepage.test.ts`</InlineFile>. Open that file and write a test case there.
🐨 Next, once `webServer` is configured, write a simple test for the homepage at <InlineFile file="./tests/e2e/homepage.test.ts">`tests/e2e/homepage.test.ts`</InlineFile>. It will be quite similar to the test you've written in the previous exercise, but now interacting and asserting directly on your application.

Once you're done, make sure that the added test is passing by running the `npm test` command in your terminal.
And, of course, verify your solution by running the tests (`npm run test:e2e`) and seeing them pass.
86 changes: 72 additions & 14 deletions exercises/01.fundamentals/02.solution.running-the-app/README.mdx
Original file line number Diff line number Diff line change
@@ -1,21 +1,79 @@
# Running the app

## Summary
## The Epic Stack

1. Introduce to the Epic Stack app, briefly.
You have noticed that things have changed in your playground directory. That's because we have an entire Epic Stack application at our disposal now. It includes React components, routes, server handlers, database schemas... That is a _lot!_ But you don't have to know it in-and-out to test it efficiently on an end-to-end level. Those implementation details vanish entirely, and all that's left is a _real_ application that you will be interacting with inside your tests.

---
But first, we need to tell Playwright to run it.

1. Changes in `playwright.config.ts`, mainly:
1. `PORT` at which the app will be running. `use.baseURL` to we can use relative URLs in our tests.
1. The `webServer` option to spawn our app and wait at the given `PORT`.
1. `reuseExistingServer` allows us to use an already running app locally for faster tests.
1. `testDir` set to `./tests/e2e` since our app now also has other types of tests.
## Configuring `webServer`

---
In `playwright.config.ts`, let's add a property `webServer` to our Playwright configuration, which will describe how our application should be spawned for testing.

1. New test at `tests/e2e/homepage.test.ts`. We need to test our app, not running `epicweb.dev`.
1. The basic structure of the test remains the same though:
1. Visit the homepage `/` (since we enabled relative URLs).
1. Assert that the heading text is visible to the user.
1. Verify the test via `npm run test:e2e`.
```ts filename=playwright.config.ts add=4-14
export default defineConfig({
// ...

webServer: {
command: process.env.CI ? 'npm run start:mocks' : 'npm run dev',
port: Number(PORT),
reuseExistingServer: true,
stdout: 'pipe',
stderr: 'pipe',
env: {
PORT,
NODE_ENV: 'test',
},
},
})
```

Here's what we are doing step-by-step:

- `command` lists the exact command Playwright will run to spawn our app. Here, we are making it conditional, using `npm run start:mocks` on CI and `npm run dev` for local testing;
- `port` is the port number where Playwright will expect the application to occupy. Once it sees something is running at that port, it will consider the application successfully spawned;
- `reuseExistingServer` tells Playwright to reuse an application on the given `port` if it's already running. This is handy when running our end-to-end tests during development so we don't have to spawn _another_ app instance;
- `stdout` and `stderr` are the quality-of-life options that `'pipe'` the standard output and errors, respectively, from the application's process to the test runner's process so we could observe them before, during, and after the test run;
- `env`, as the name suggests, lists the environment variables we can provide to the application:
- `PORT` to spawn the application at the same port that Playwright expects it on;
- `NODE_ENV` as a flag that tells our app that it's being run for testing purposes.

<callout-success>You can provide an array of web server configurations in the `webServer` option for Playwright to spawn _them all in parallel_ before the test run.</callout-success>

## Writing tests

From the test's perspective, not much will change. We still want to navigate to the page we want (the homepage), find the heading element, and make sure that it's visible to the user.

```ts filename=tests/e2e/homepage.test.ts
import { test, expect } from '@playwright/test'

test('displays the welcome heading', async ({ page }) => {
await page.goto('/')

await expect(
page.getByRole('heading', { name: 'The Epic Stack' }),
).toBeVisible()
})
```

Notice that I'm providing a _relative_ path to the homepage in `page.goto('/')`. How does Playwright know what to resolve it relatively to? It doesn't! I told it in `playwright.config.ts`:

```ts filename=playwright.config.ts add=4-6
export default defineConfig({
// ...

use: {
baseURL: `http://localhost:${PORT}/`,
},
})
```

<callout-success>Playwright resolves any relative URLs against `use.baseURL`, if provided.</callout-success>

## Running the test

Finally, let's run the test to verify that our application is spawned correctly and all the instructions we have in our test run against that spawned instance.

```
npm run test:e2e
```
18 changes: 16 additions & 2 deletions exercises/01.fundamentals/03.problem.custom-fixtures/README.mdx
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
# Custom fixtures

One thing you will do in every end-to-end test—and do quite often—is navigating between pages. As such, it would be great if you could catch mistakes in navigation before you run your tests.

For example, when making a typo in the page's URL:

```ts
await page.goto('/hoempage')
```

> If you're a fast typer like me, you know how painful this is 😔.

While this certainly will fail your test, the error you'll get will be a _result_ of the mistake (navigation timeout, failed to find elements, timing out assertions), not **the mistake itself**.

We can do better here. We can make such mistakes produce a _type error_, notifying us way before we run the tests. We can have **type-safe routing** in our tests!

## Your task

👨‍💼 In this one, you are going to implement a custom fixture called `navigate`. The purpose of this fixture is to make page navigations in tests _type-safe_! You will use the types generated by React Router to achieve that.
👨‍💼 To achieve type-safe routing, you are going to implement a custom fixture called `navigate`. The purpose of this fixture is to _infer the route types_ from React Router and, as a result, make every page navigation in your tests type-safe.

🐨 Start by opening the <InlineFile file="./tests/test-extend.ts">`tests/test-extend.ts`</InlineFile> file I've created for you. Follow the steps in that file to implement the `navigate` fixture.

🐨 Once the fixture is ready, refactor the <InlineFile file="./tests/e2e/homepage.test.ts">`tests/e2e/homepage.test.ts`</InlineFile> test suite to use the newly created `navigate()` fixture.

And, of course, verify that the tests are passing in the end by running `npm test`. Good luck!
And, of course, verify that the tests are passing in the end by running `npm test:e2e`. Good luck!
120 changes: 117 additions & 3 deletions exercises/01.fundamentals/03.solution.custom-fixtures/README.mdx
Original file line number Diff line number Diff line change
@@ -1,9 +1,123 @@
# Custom fixtures

## Summary
## Type-safe navigation

1. Implement the new `navigate` fixture in `tests/text-extend.ts`. Use generated type definitions from React Router for type-safe navigation in tests. Build the app to generate the type definitions.
1. Change the existing test at `tests/e2e/homepage.test.ts` to use the new `navigate` fixture instead of `page`. Notice the route suggestions! Neat.
It's important to understand that type-safe navigation is not a testing problem. It's an app problem. Just as you can make a mistake by visiting a non-existing page in your test, you can do something much worse—include a link to a non-exsisting page in your app and break your users' experience:

```tsx
<Link to="/hoempage" />
```

This is why every major web framework nowadays comes with type-safe routing. The latter is achieved by _generating type definitions_ for your routes and annotating your navigation-facing APIs, like `<Link />` or `href()`, with them.

In React Router, those type definitions are generated at `.react-router/types/+routes.ts`:

```ts filename=.react-router/types/+routes.ts
type Pages = {
'/': {
params: {}
}
'/messages/:id': {
params: {
id: string
}
}
}
```

We are going to benefit from these generated types in our tests, too!

## Creating a fixture

Rightfully, Playwright doesn't know what web framework you're using, if you're using one at all. It has no idea about the type definitions it generates. This is where we have to _teach it_, and we will do so by creating our custom fixture called `navigate()` that will replace `page.goto()` for navigations within our app.

Create a file at `./tests/test-extend.ts` and declare a `Fixtures` interface in it. There, describe the `navigate` key with the type of a function mirroring the `href()` from `react-router`:

```ts filename=tests/test-extend.ts highlight=4-6
import { href, type Register } from 'react-router'

interface Fixtures {
navigate: <T extends keyof Register['pages']>(
...args: Parameters<typeof href<T>>
) => Promise<void>
}
```

> Here, we are using a _type argument_ `T` to infer all the keys (pathnames) from `Register['pages']` type in React Router and provide them to the `href<T>` parameters so the call signature of `navigate()` is the same as that of `href()`.

Implementing a custom fixture means _extending the default `test()` function_ in Playwright. So let's import it first and call `.extend()` on it, assigning the result into a variable called `test` and exporting it:

```ts filename=tests/test-extend.ts add=1,10-16
import { test as testBase } from '@playwright/test'
import { href, type Register } from 'react-router'

interface Fixtures {
navigate: <T extends keyof Register['pages']>(
...args: Parameters<typeof href<T>>
) => Promise<void>
}

export const test = testBase.extend<Fixtures>({
navigate: async ({ page }, use) => {
await use(async (...args) => {
await page.goto(href(...args))
})
},
})
```

Playwright fixtures follow a similar pattern as Vitest fixtures (that's not a coincidence):

```ts
{
[fixtureName]: (context, use) => {}
}
```

In our navigate fixture, we are taking the `page` object from the test context and tapping into `page.goto()` to trigger the actual navigation in the test. The key here is that we are also using the `href()` function from `react-router` to actually _build_ the end URL we are naviugating to:

```ts
await page.goto(href(...args))
```

You can visualize the function invocation here like this:

```
navigate('/messages/:id', { id: '1' })
→ href('/messages/:id', { id: '1'} ) // "/messages/1"
→ page.goto('/messages/1')
```

Lastly, because we are going to be importing `test` from our `test-extend.ts` from now on, it would be great to also re-export the `expect` function for consistent and ergonomic imports in our tests:

```ts filename=tests/test-extend.ts add=1,5
import { test as testBase, expect } from '@playwright/test'

// ...

export { expect }
```

## Using custom fixtures

Both built-in and custom fixtures in Playwright are accessed via the test context. What makes the custom fixtures work is that we are _replacing_ the default `test` function from `@playwright/test` with the one extended with our fixtures.

```ts filename=tests/e2e/homepage.test.ts remove=1,4,6 add=2,5,7
import { test, expect } from '@playwright/test'
import { test, expect } from '#tests/test-extend.ts'

test('displays the welcome heading', async ({ page }) => {
test('displays the welcome heading', async ({ page, navigate }) => {
await page.goto('/')
await navigate('/')

await expect(
page.getByRole('heading', { name: 'The Epic Stack' }),
).toBeVisible()
})
```

> Note that while it's technically possible to extend built-in fixtures, like `page.goto()`, you have to be mindful about the scope of your customization. Not every `page.goto()` call is a navigation within our app, but every `navigate()` call is.

## Related materials

Expand Down
22 changes: 21 additions & 1 deletion exercises/02.authentication/01.problem.basic/README.mdx
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
# Basic

- Testing a basic (email+password) authentication.
The most common authentication method you can find on the web is the one using email and password. This means that it's extremely likely to appear in your own apps in one form or another, which, in turn, makes it something you have to be comfortable with testing.

## Your task

👨‍💼 Here's your goal for this exercise: cover the basic authentication feature of the Epic Stack app with end-to-end tests (a happy path and a single error handling scenario will suffice). But before you can get to writing those, you have to _set things up_.

🐨 In <InlineFile filename="./tests/db-utils.ts">`tests/db-utils.ts`</InlineFile>, follow the instructions to implement a `createUser()` helper utility. It will come in handy for creating test users in the database so you have the credentials to authenticate with during your test.

🐨 Next, head to <InlineFile filename="./tests/e2e/authentication-basic.test.ts">`tests/e2e/authentication-basic.test.ts`</InlineFile> and write the first test case for a successful authentication flow. It will consist of:

1. Using the `createUser()` utility to get test credentials;
1. Navigating to the Login page;
1. Filling in the login form with the said credentials;
1. Submitting the form;
1. And, finally, asserting that authentication-dependent UI is visible on the page.

Verify your test as passing by running `npm run test:e2e`.

🐨 Finally, add another test case in the same test file for the error handling during the authentication flow. To trigger it, simply try logging in with non-existing credentials and assert that a meaningful error message is presented to the user on the page.

See you once you're done!
Loading
Loading