mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
[Doc update] Udated testing.md (#56)
* Changed fixtures used in accounts that dont need to check sign-in functionality * Updated testing doc: added fixture info and expanded on POM pattern * Update testing.md * Udated testing.md Added Superbase emulator workaround
This commit is contained in:
committed by
GitHub
parent
82eab67525
commit
dfd0dc98ac
224
docs/testing.md
224
docs/testing.md
@@ -136,7 +136,7 @@ web/
|
||||
|
||||
### Jest Unit Testing Guide
|
||||
|
||||
This guide provides guidelines and best practices for writing unit tests using Jest in this project. Following these
|
||||
These are the guidelines and best practices we follow when writing unit tests using Jest in this project. Following these
|
||||
standards ensures consistency, maintainability, and comprehensive test coverage.
|
||||
|
||||
#### Best Practices
|
||||
@@ -348,7 +348,6 @@ jest.mock('path/to/module')
|
||||
* This creates an object containing all named exports from ./path/to/module
|
||||
*/
|
||||
import * as mockModule from 'path/to/module'
|
||||
|
||||
;(mockModule.module as jest.Mock).mockResolvedValue(mockReturnValue)
|
||||
```
|
||||
|
||||
@@ -493,14 +492,28 @@ jest.spyOn(Array.prototype, 'includes').mockImplementation(function (value) {
|
||||
|
||||
# Playwright (E2E) Testing Guide
|
||||
|
||||
E2E tests use [Playwright](https://playwright.dev/) and run against a fully isolated local stack:
|
||||
These are the guidelines and best practices we follow when writing E2E tests using [Playwright](https://playwright.dev/) in this project. Following these
|
||||
standards ensures consistency, maintainability, and comprehensive test coverage.
|
||||
|
||||
- **Supabase** (Postgres) via `npx supabase start`
|
||||
- **Firebase** (Auth and Storage) via `firebase emulators:start`
|
||||
E2E tests live in `tests/e2e/` and follow the `*.spec.ts` naming convention, when executed they are run against a fully isolated local stack:
|
||||
|
||||
- **Supabase** (Postgres)
|
||||
- **Firebase** (Auth and Storage)
|
||||
- **Backend API** (`backend/api`)
|
||||
- **Next.js frontend** (`web`)
|
||||
|
||||
Tests live in `tests/e2e/` and follow the `*.e2e.spec.ts` naming convention.
|
||||
### Best Practices
|
||||
|
||||
1. Test one scenario per test - Each test should verify a single behavior.
|
||||
2. Keep tests independent - Each test should be fully independent.
|
||||
3. Use locators that reflect how users will interact with the application - Outlined in the [Component Selection Hierarchy](###component-selection-hierarchy) below.
|
||||
4. Use the Page Object Model (POM) - Keeps the test files lightweight and easily readable at a glance.
|
||||
5. Store authentication state - Persist login state via `storageState` and reuse it across tests where needed.
|
||||
6. Use fixtures for setup and teardown - Keeps test files lightweight.
|
||||
7. Use web-first assertions - Use Playwright's built-in `expect` assertions (e.g., `toBeVisible`, `toHaveText`) which auto-retry until the condition is met.
|
||||
8. Use environment variables for config - Store credentials, base URLs, and environment-specific settings in env vars rather than hardcoding them in test files.
|
||||
9. Organise tests with a clear folder structure - Separate test files, page objects, fixtures, and helpers into distinct directories for scalability and maintainability.
|
||||
10. Integrate with CI/CD - Run Playwright tests in headless mode in your pipeline, alerting you of possible issues early.
|
||||
|
||||
---
|
||||
|
||||
@@ -603,9 +616,15 @@ This opens a visual browser interface where you can:
|
||||
- 🔄 Re-run tests without restarting anything
|
||||
- 🕵️ Time-travel debug through test steps
|
||||
|
||||
Alternatively if you only want to open the Playwright UI you can use:
|
||||
|
||||
```bash
|
||||
npx playwright test --ui
|
||||
```
|
||||
|
||||
### 3. Edit tests and re-run
|
||||
|
||||
Edit your `*.e2e.spec.ts` file, save, then click **Run** in the Playwright UI.
|
||||
Edit your `*.spec.ts` file, save, then click **Run** in the Playwright UI.
|
||||
No restart needed for test file changes.
|
||||
|
||||
### 4. Reset data when needed
|
||||
@@ -672,10 +691,10 @@ tests/
|
||||
└── e2e/
|
||||
├── web/
|
||||
│ └── specs/
|
||||
│ └── auth.e2e.spec.ts
|
||||
│ └── auth.spec.ts
|
||||
└── backend/
|
||||
└── specs/
|
||||
└── api.e2e.spec.ts
|
||||
└── api.spec.ts
|
||||
```
|
||||
|
||||
### Component Selection Hierarchy
|
||||
@@ -712,23 +731,62 @@ This hierarchy mirrors how users actually interact with your application, making
|
||||
Tests often receive multiple page objects as fixtures (e.g. `homePage`, `authPage`, `profilePage`). This is the **Page
|
||||
Object Model** pattern — a way to organize selectors and actions by the area of the app they belong to.
|
||||
|
||||
**Page objects are not separate browser tabs.** They are all wrappers around the same underlying `page` instance. Each
|
||||
class simply encapsulates the selectors and actions relevant to one part of the UI:
|
||||
**Page objects** are all wrappers around the same underlying `page` instance. Each
|
||||
class simply encapsulates the selectors and actions relevant to an entire page of the application.
|
||||
|
||||
The `app.ts` file improves scalability by acting as a central hub for page objects and shared modules. Instead of importing 40 different pages into a test, modules can be accessed through `app.ts`, making tests cleaner and easier to maintain. It also supports functionality that spans multiple pages.
|
||||
|
||||
```typescript
|
||||
class ProfilePage {
|
||||
constructor(private page: Page) {}
|
||||
//profilePage.ts
|
||||
import {expect, Locator, Page} from '@playwright/test'
|
||||
|
||||
export class ProfilePage {
|
||||
private readonly displayName: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.displayName = page.getByTestId('display-name')
|
||||
}
|
||||
|
||||
async verifyDisplayName(name: string) {
|
||||
await expect(this.page.getByTestId('display-name')).toHaveText(name)
|
||||
await expect(this.displayName).toBeVisible()
|
||||
await expect(this.displayName).toHaveText(name)
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsPage {
|
||||
constructor(private page: Page) {} // same page instance
|
||||
//settingsPage.ts
|
||||
import {expect, Locator, Page} from '@playwright/test'
|
||||
|
||||
export class SettingsPage {
|
||||
private readonly deleteAccountButton: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.deleteAccountButton = page.getByRole('button', {name: 'Delete account'})
|
||||
}
|
||||
|
||||
async deleteAccount() {
|
||||
await this.page.getByRole('button', {name: 'Delete account'}).click()
|
||||
await expect(this.deleteAccountButton).toBeVisible()
|
||||
await this.deleteAccountButton.click()
|
||||
}
|
||||
}
|
||||
|
||||
//app.ts
|
||||
import {ProfilePage} from './profilePage'
|
||||
import {SettingsPage} from './settingsPage'
|
||||
|
||||
export class App {
|
||||
readonly profile: ProfilePage
|
||||
readonly settings: SettingsPage
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.profile = new ProfilePage(page)
|
||||
this.settings = new SettingsPage(page)
|
||||
}
|
||||
|
||||
//Methods that span multiple pages can be outlined here
|
||||
async verifyAccountThenDelete(name: string) {
|
||||
this.profile.verifyDisplayName(name)
|
||||
//navigation to the settings page
|
||||
this.settings.deleteAccount()
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -747,9 +805,9 @@ await page.locator('[data-testid="skip-onboarding"]').click()
|
||||
// ...50 more lines of noise
|
||||
|
||||
// ✅ With POM — readable and maintainable
|
||||
await registerWithEmail(homePage, authPage, fakerAccount)
|
||||
await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, fakerAccount)
|
||||
await profilePage.verifyDisplayName(fakerAccount.display_name)
|
||||
await app.registerWithEmail(fakerAccount)
|
||||
await app.skipOnboardingHeadToProfile(fakerAccount)
|
||||
await app.profile.verifyDisplayName(fakerAccount)
|
||||
```
|
||||
|
||||
**What happens if you call a method on the "wrong" page object?**
|
||||
@@ -761,20 +819,93 @@ won't find its element and the test will **time out**.
|
||||
|
||||
```typescript
|
||||
// ⚠️ This fails at runtime if navigation hasn't happened yet
|
||||
await settingsPage.deleteAccount() // navigates away from profile
|
||||
await profilePage.verifyDisplayName(name) // locator not found → timeout
|
||||
await app.settings.deleteAccount() // navigates away from profile
|
||||
await app.profile.verifyDisplayName(name) // locator not found → timeout
|
||||
```
|
||||
|
||||
Always ensure navigation has completed before calling methods that depend on a specific screen being visible.
|
||||
|
||||
### Fixtures
|
||||
|
||||
To further improve readability, and simplify the creation/implimentation of tests, fixtures are used for test case setup and teardown where appropriate.
|
||||
|
||||
```typescript
|
||||
//baseFixture.ts
|
||||
import {test as base} from '@playwright/test'
|
||||
import {App} from './app.ts'
|
||||
import {testAccounts, UserAccountInformation} from './accountInformation'
|
||||
import {deleteUser} from './deleteUser'
|
||||
import {seedUser} from './seedDatabase'
|
||||
|
||||
export const test = base.extend<{
|
||||
app: App
|
||||
signedInAccount: UserAccountInformation
|
||||
}>({
|
||||
//This gives access to the entire POM structure for the application
|
||||
app: async ({page}, use) => {
|
||||
const appPage = new App(page)
|
||||
await use(appPage)
|
||||
},
|
||||
/**
|
||||
* This generates a test account
|
||||
* Seeds the database with the user
|
||||
* Signs the user into the app
|
||||
* Executes the test
|
||||
* Deletes the user after execution is complete
|
||||
*/
|
||||
signedInAccount: async ({app}: {app: App}, use) => {
|
||||
const account = testAccounts.faker_account()
|
||||
await seedUser(account.email, account.password)
|
||||
await app.signinWithEmail(account)
|
||||
await use(account)
|
||||
await deleteUser(account)
|
||||
},
|
||||
})
|
||||
|
||||
export {expect} from '@playwright/test'
|
||||
```
|
||||
|
||||
```typescript
|
||||
//test.spec.ts
|
||||
//This is an example of how the above fixture would be used in a test
|
||||
import {expect, test} from './fixtures/baseFixture'
|
||||
|
||||
test.describe('when given valid input', () => {
|
||||
test('should already be signed into the correct account', async ({
|
||||
signedInAccount, //This is the fixture that contains the account information and is already signed in
|
||||
app, //This is the fixture that gives access to the entire POM structure
|
||||
}) => {
|
||||
await app.home.goToProfilePage()
|
||||
await app.profile.verifyDisplayName(signedInAccount.display_name)
|
||||
})
|
||||
})
|
||||
|
||||
//This is how the test would look without the fixture
|
||||
import {test, expect} from '@playwright/test'
|
||||
import {App} from './app.ts'
|
||||
import {testAccounts, UserAccountInformation} from './accountInformation'
|
||||
import {deleteUser} from './deleteUser'
|
||||
|
||||
test.describe('when given valid input', () => {
|
||||
test('should already be signed into the correct account', async ({page}) => {
|
||||
const app = new App(page)
|
||||
const account = testAccounts.faker_account()
|
||||
|
||||
await app.auth.signUpWithEmail(account)
|
||||
await app.home.goToProfilePage()
|
||||
await app.profile.verifyDisplayName(signedInAccount.display_name)
|
||||
await deleteUser(account)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Setting up test data
|
||||
|
||||
Since the tests run in parallel (i.e., at the same time) and share the same database and Firebase emulator, it can
|
||||
create issues where one tests edits or deletes data that another test is using, hence breaking that test.
|
||||
|
||||
The standard solution for shared data is **test isolation via unique data per test**. Each test generates its own unique
|
||||
identifiers so
|
||||
they never touch each other's data.
|
||||
identifiers so they never touch each other's data.
|
||||
|
||||
**1. Use unique emails/username/IDs per test**
|
||||
|
||||
@@ -792,16 +923,8 @@ This way no two tests share the same user, so deletes/reads never conflict.
|
||||
|
||||
**2. Cleanup only your own data**
|
||||
|
||||
Each test must fully attend to their own (and only their own) garden, by tracking what it created and cleaning up only
|
||||
that:
|
||||
|
||||
```js
|
||||
afterEach(async () => {
|
||||
await deleteUser(email, password) // only the one this test created
|
||||
})
|
||||
```
|
||||
|
||||
Avoid `deleteAllUsers()` or broad wipes in parallel tests — that's what causes race conditions.
|
||||
Each test must fully attend to their own (and only their own) data, by tracking what it created and cleaning up only
|
||||
that, correct use of fixtures helps data cleanup.
|
||||
|
||||
**3. If you must share fixtures, use read-only shared data**
|
||||
|
||||
@@ -825,21 +948,13 @@ This gives each parallel worker its own emulator namespace, so even aggressive c
|
||||
|
||||
This is not implemented yet, but it will be very userful as the playwright test suite grows.
|
||||
|
||||
**Recommended approach in practice:**
|
||||
|
||||
- Unique email/username/ID per test → no sharing, no conflict
|
||||
- `afterEach` cleans up only own data
|
||||
- `beforeAll` seeds any read-only shared fixtures once
|
||||
|
||||
This eliminates race conditions without needing locks or sequencing.
|
||||
|
||||
### Example test
|
||||
|
||||
```typescript
|
||||
import {test, expect} from '@playwright/test'
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
test('should login successfully', async ({page}) => {
|
||||
test.describe('When given valid input', () => {
|
||||
test('this should login successfully', async ({page}) => {
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', {name: 'Sign In'}).click()
|
||||
await page.getByLabel('Email').fill('test@example.com')
|
||||
@@ -868,6 +983,27 @@ For comprehensive troubleshooting guidance beyond testing-specific issues, see
|
||||
the [Troubleshooting Guide](troubleshooting.md) which covers development environment setup, database and emulator
|
||||
issues, API problems, and more.
|
||||
|
||||
### Supabase emulator not working
|
||||
|
||||
There might be compatability issues with the Supabase emulator and your setup this can cause a `Runtime error` on the app pointing to an issue in the `supabase/utils.ts (69: 17)` file, and the Supabase emulator showing a generic `site can't be reached` browser error.
|
||||
|
||||
The workaround for this is to use a remote db and the local firebase emulator
|
||||
|
||||
Install DBeaver (contact the main maintainer for the postgres db connection info) to view and edit the database
|
||||
|
||||
```bash
|
||||
# Comment out "Object.assign(process.env, supabaseEnv)" in playwright.config.ts
|
||||
|
||||
# This launches the Firebase Emulator
|
||||
yarn emulate
|
||||
|
||||
# This launches the app
|
||||
yarn dev
|
||||
|
||||
# Launch Playwright
|
||||
npx playwright test --ui
|
||||
```
|
||||
|
||||
### Port already in use
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {sleep} from 'common/util/time'
|
||||
import {sleep} from 'common/src/util/time'
|
||||
|
||||
import {TEST_USER_DISPLAY_NAME} from '../../utils/seedDatabase'
|
||||
import {expect, test} from '../fixtures/signInFixture'
|
||||
@@ -28,8 +28,7 @@ test.describe('when given valid input', () => {
|
||||
})
|
||||
|
||||
test.describe('the applied filter should', () => {
|
||||
test('update the profile count', async ({app, signedOutAccount: account}) => {
|
||||
await app.signinWithEmail(account)
|
||||
test('update the profile count', async ({app, signedInAccount}) => {
|
||||
await app.home.clickPeopleLink()
|
||||
|
||||
const totalProfiles = await app.people.profileCountLocator.textContent()
|
||||
@@ -55,8 +54,7 @@ test.describe('when given valid input', () => {
|
||||
* Test fails due to ui not updating
|
||||
* works fine manually
|
||||
*/
|
||||
test.skip('show profiles with the correct age', async ({app, signedOutAccount: account}) => {
|
||||
await app.signinWithEmail(account)
|
||||
test.skip('show profiles with the correct age', async ({app, signedInAccount}) => {
|
||||
await app.home.clickPeopleLink()
|
||||
await app.people.setDisplayFilter({filters: {Age: true}})
|
||||
|
||||
@@ -76,8 +74,7 @@ test.describe('when given valid input', () => {
|
||||
await expect(parseInt(totalProfiles)).not.toEqual(parseInt(filteredProfiles))
|
||||
})
|
||||
|
||||
test('show profiles with the correct gender', async ({app, signedOutAccount: account}) => {
|
||||
await app.signinWithEmail(account)
|
||||
test('show profiles with the correct gender', async ({app, signedInAccount}) => {
|
||||
await app.home.clickPeopleLink()
|
||||
|
||||
const totalProfiles = await app.people.profileCountLocator.textContent()
|
||||
@@ -89,9 +86,8 @@ test.describe('when given valid input', () => {
|
||||
|
||||
test('show profiles with the correct education level', async ({
|
||||
app,
|
||||
signedOutAccount: account,
|
||||
signedInAccount,
|
||||
}) => {
|
||||
await app.signinWithEmail(account)
|
||||
await app.home.clickPeopleLink()
|
||||
|
||||
const totalProfiles = await app.people.profileCountLocator.textContent()
|
||||
@@ -101,8 +97,7 @@ test.describe('when given valid input', () => {
|
||||
await app.people.verifyProfileCount(totalProfiles)
|
||||
})
|
||||
|
||||
test('show profiles with the correct diet', async ({app, signedOutAccount: account}) => {
|
||||
await app.signinWithEmail(account)
|
||||
test('show profiles with the correct diet', async ({app, signedInAccount}) => {
|
||||
await app.home.clickPeopleLink()
|
||||
|
||||
const totalProfiles = await app.people.profileCountLocator.textContent()
|
||||
@@ -114,9 +109,8 @@ test.describe('when given valid input', () => {
|
||||
|
||||
test('show profiles with the correct smoking preference', async ({
|
||||
app,
|
||||
signedOutAccount: account,
|
||||
signedInAccount,
|
||||
}) => {
|
||||
await app.signinWithEmail(account)
|
||||
await app.home.clickPeopleLink()
|
||||
|
||||
const totalProfiles = await app.people.profileCountLocator.textContent()
|
||||
@@ -128,9 +122,8 @@ test.describe('when given valid input', () => {
|
||||
|
||||
test('show profiles with the correct psychedelics preference', async ({
|
||||
app,
|
||||
signedOutAccount: account,
|
||||
signedInAccount,
|
||||
}) => {
|
||||
await app.signinWithEmail(account)
|
||||
await app.home.clickPeopleLink()
|
||||
|
||||
const totalProfiles = await app.people.profileCountLocator.textContent()
|
||||
@@ -142,9 +135,8 @@ test.describe('when given valid input', () => {
|
||||
|
||||
test('show profiles with the correct cannabis preference', async ({
|
||||
app,
|
||||
signedOutAccount: account,
|
||||
signedInAccount,
|
||||
}) => {
|
||||
await app.signinWithEmail(account)
|
||||
await app.home.clickPeopleLink()
|
||||
|
||||
const totalProfiles = await app.people.profileCountLocator.textContent()
|
||||
@@ -158,9 +150,8 @@ test.describe('when given valid input', () => {
|
||||
|
||||
test('show profiles with the correct political preference', async ({
|
||||
app,
|
||||
signedOutAccount: account,
|
||||
signedInAccount,
|
||||
}) => {
|
||||
await app.signinWithEmail(account)
|
||||
await app.home.clickPeopleLink()
|
||||
|
||||
const totalProfiles = await app.people.profileCountLocator.textContent()
|
||||
@@ -172,9 +163,8 @@ test.describe('when given valid input', () => {
|
||||
|
||||
test('show profiles with the correct religion preference', async ({
|
||||
app,
|
||||
signedOutAccount: account,
|
||||
signedInAccount,
|
||||
}) => {
|
||||
await app.signinWithEmail(account)
|
||||
await app.home.clickPeopleLink()
|
||||
|
||||
const totalProfiles = await app.people.profileCountLocator.textContent()
|
||||
@@ -186,8 +176,7 @@ test.describe('when given valid input', () => {
|
||||
})
|
||||
|
||||
test.describe('the hide profile feature', () => {
|
||||
test('should correctly hide a profile', async ({app, signedOutAccount: account}) => {
|
||||
await app.signinWithEmail(account)
|
||||
test('should correctly hide a profile', async ({app, signedInAccount}) => {
|
||||
await app.home.clickPeopleLink()
|
||||
await app.people.useSearch(TEST_USER_DISPLAY_NAME)
|
||||
await sleep(1000)
|
||||
@@ -205,8 +194,7 @@ test.describe('when given valid input', () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should be reversible using undo', async ({app, signedOutAccount: account}) => {
|
||||
await app.signinWithEmail(account)
|
||||
test('should be reversible using undo', async ({app, signedInAccount}) => {
|
||||
await app.home.clickPeopleLink()
|
||||
await app.people.useSearch(TEST_USER_DISPLAY_NAME)
|
||||
await sleep(1000)
|
||||
@@ -230,9 +218,8 @@ test.describe('when given valid input', () => {
|
||||
|
||||
test('should be reversible using manage hidden profiles feature in settings', async ({
|
||||
app,
|
||||
signedOutAccount: account,
|
||||
signedInAccount,
|
||||
}) => {
|
||||
await app.signinWithEmail(account)
|
||||
await app.home.clickPeopleLink()
|
||||
await app.people.useSearch(TEST_USER_DISPLAY_NAME)
|
||||
await sleep(1000)
|
||||
|
||||
Reference in New Issue
Block a user