Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add vitest for React core UI #72

Merged
merged 7 commits into from
Dec 20, 2024
Merged
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
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Fixes [list issues/bugs if needed]
- [ ] PR should have one of the following labels:
- `Feature 🎁`, `Breaking Change 💣`, `Bug 🐛`, `Documentation 📝`, `Maintenance 🔨`.
- [ ] All packages define the required scripts in `package.json`:
- [ ] All packages: `check` and `check:fix`.
- [ ] All packages: `check`, `check:fix`, and `test`.
- [ ] Packages with a build step: `build`.

## Screenshots
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ jobs:
uses: ./.github/actions/setup-env

- name: Code quality checks
run: bun run check
run: bun run check

- name: Test
run: bun run test
3 changes: 3 additions & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ jobs:

- name: Code quality checks
run: bun run check

- name: Test
run: bun run test
3 changes: 3 additions & 0 deletions .github/workflows/tag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ jobs:
- name: Code quality checks
run: bun run check

- name: Test
run: bun run test

version:
name: Bump package versions
runs-on: ubuntu-latest
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ in the monorepo.

Each package should define the following scripts in `package.json`:

- `build`: Builds the package as preparation for publishing or use by its dependencies.
- `check`: Lints, formats, and (if applicable) type-checks the package.
- `check:fix`: Lints, formats, type-checks, and fixes issues where possible.
- `test`: Runs tests for the package.

If a package has a build step, it should also define:
- `build`: Builds the package as preparation for publishing or use by its dependencies.
advl marked this conversation as resolved.
Show resolved Hide resolved

By creating these scripts in each package, the build and check tasks will be
included in the monorepo's CI workflow, helping to avoid build errors later.
Expand All @@ -69,7 +72,7 @@ JavaScript documentation should be written with [TSDoc](https://tsdoc.org/).

The `@types` dependencies required by the monorepo packages and apps are by default hoisted in the root `node_modules`. However, this behaviour has two downsides :
- It can lead to conflicts between the types of different packages, for instance `bun` and `node` export overlapping types.
- Consumption of types in packages and apps are not explicit, leading to hard to debug issues.
- Consumption of types in packages and apps are not explicit, leading to issues that are difficult to debug.
advl marked this conversation as resolved.
Show resolved Hide resolved

For this reason, we have opted for explicitly declaring the `@types` dependencies both in the `package.json` under `@devDependencies` and in `tsconfig.json` under `compilerOptions/types` for each package and app.

Expand Down
Binary file modified bun.lockb
Binary file not shown.
204 changes: 204 additions & 0 deletions guides/TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
## Testing Framework: Vitest

We have chosen [Vitest](https://vitest.dev/) as our React testing framework for our design system engineering team.
This decision is based on its speed, simplicity, and excellent integration with modern JavaScript ecosystems.

### Why Vitest?

1. **Performance**: Vitest offers superior speed, especially in watch mode.
2. **Simplicity**: It provides a straightforward setup, particularly for modern JavaScript projects.
3. **ES Module Support**: Vitest has great native support for ES modules.
4. **Vite Integration**: Vitest offers great integration for Vite projects (which many of our packages are). It is also easy to integrate with projects that don't use Vite.
advl marked this conversation as resolved.
Show resolved Hide resolved

### Setup and Configuration

#### Installing Vitest
Install Vitest: `bun add -d vitest`

#### Vitest Config (vitest.config.ts)
Create `vitest.config.ts` in the root of a package. The structure of this file varies based on whether your project is using Vite or not.
Example configurations for Vite and non-Vite projects are given below.
See the [Vitest documentation](https://vitest.dev/config/file.html#managing-vitest-config-file) for more information.

##### Vite Projects
Define a test configuration in `vitest.config.ts` that merges your main Vite config with the test configuration.
Use `defineConfig` from `vitest/config` to define the test configuration, and `mergeConfig` to merge it with the Vite configuration.
```typescript
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";

export default mergeConfig(
// Base the test config on the base vite config
viteConfig,
defineConfig({
test: {
// use JS DOM for browser-like test environment
environment: "jsdom",
// include vite globals for terser test code
globals: true,
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
},
}),
);
````

##### Non-Vite Projects
Define a test configuration in `vitest.config.ts` and export it using `defineConfig` from `vitest/config`.
```typescript
// vitest.config.ts
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";

export default defineConfig({
test: {
// use JS DOM for browser-like test environment
environment: "jsdom",
// include vite globals for terser test code
globals: true,
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
}
});
```

#### Typescript Configuration (tsconfig.json)
Add Vite globals and your configuration file to your Typescript configuration.
These do not have to be included in your build configuration. They are only necessary for type-checking and testing.
```json5
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["vite.config.ts", "vitest.config.ts"]
}
```
#### Testing environment

Vitest supports multiple testing environments:
- Node (default)
- JSDom (browser-like environment)
- happy-dom
- edge-runtime

We have chosen to use JSDom as our testing environment for front-end code.
This environment is the most similar to a browser environment, making it ideal for testing components.
You may use other environments based on your needs. See the [Vitest documentation](https://vitest.dev/config/#environment) for more information.

##### Installing a testing environment
If using a non-node testing environment, install the corresponding package: `bun add -d <jsdom|happy-dom|edge-runtime>`.

#### React Testing Library

We've chosen [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) to complement Vitest for testing React components.
This enables us to write tests that are well-suited for React components by directly render components and interacting with them.

##### Why React Testing Library?

1. **User-centric testing**: It promotes testing behavior over implementation.
2. **Simplicity**: Provides a simple and intuitive API for testing React components.
3. **Accessibility**: Encourages writing accessible components by default.

Install React Testing Library: `bun add -d @testing-library/react`

#### Jest-dom (optional)

[Jest-dom](https://www.npmjs.com/package/@testing-library/jest-dom) provides a set of custom element matchers.
These can be used to extend vitest matchers to make it easier to test the state of the DOM.
It is widely used, both [within Canonical](https://github.com/search?q=org%3Acanonical+jest-dom+vitest+%40testing-library%2Freact&type=code) and in the wider community.

Install Jest-dom: `bun add -d @testing-library/jest-dom`

##### Configuring Jest-dom
Create a `vitest.setup.ts` file in the root of your package and import Jest-dom's matchers.
```typescript
// vitest.setup.ts
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// Extends vitest's matchers with jest-dom's matchers
import "@testing-library/jest-dom/vitest";

// Cleanup the DOM after each test
afterEach(() => {
cleanup();
});
```
Add the setup file to your `vitest.config.ts`:
```typescript
// vitest.config.ts
import { defineConfig } from "vitest/config";
import viteConfig from "./vite.config.js";

export default defineConfig({
setupFiles: ["./vitest.setup.ts"],
// the rest of your config options...
});
````
Finally, add the setup file to your `tsconfig.json` to enable type-checking it.
```json5
// tsconfig.json
{
"compilerOptions": {
"types": [
"vitest/globals",
"@testing-library/jest-dom"
]
},
"include": [
"vite.config.ts",
"vitest.config.ts",
"vitest.setup.ts"
]
}
```
#### Writing Tests

Vitest provides a simple and intuitive API for writing tests.
Tests should be written to target specific components or features, ensuring that they are isolated and focused.

Components should be tested based on their behavior, not their implementation details.
This can be done by targeting elements by their text content, role, or other accessible attributes, instead of their structure.

An example is provided that covers many common testing scenarios:
```tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import Component from "./Chip.js";

describe("Chip component", () => {
// Test basic rendering
it("renders", () => {
render(<Component lead={"Cloud"} value={"AWS"}/>);
expect(screen.getByText("AWS")).toBeInTheDocument();
});

// Test passing in props and nesting elements
it("applies lead & value", () => {
render(<Component lead={"Cloud"} value={"AWS"}/>);
const leadElement = screen.getByText("Cloud");
const valueElement = screen.getByText("AWS");

const chipElement = leadElement.closest(".ds.chip");
expect(chipElement).toBeInTheDocument();
expect(chipElement).toContainElement(leadElement);
expect(chipElement).toContainElement(valueElement);
});

// Test CSS classes
it("applies positive appearance", () => {
render(<Component lead="Cloud" value="AWS" appearance="positive"/>);

const leadElement = screen.getByText("Cloud");
const chipElement = leadElement.closest(".ds.chip");

expect(chipElement).toHaveClass("positive");
});

// Test event handling
it("calls onClick", () => {
const onClick = vi.fn();
render(<Component lead={"Cloud"} value={"AWS"} onClick={onClick}/>);
screen.getByText("AWS").click();
expect(onClick).toHaveBeenCalled();
});
});
```
4 changes: 4 additions & 0 deletions nx.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"check:fix": {
"dependsOn": ["^check:fix"],
"cache": true
},
"test": {
"dependsOn": ["^build"],
"cache": true
}
advl marked this conversation as resolved.
Show resolved Hide resolved
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"lerna": "lerna",
"check": "lerna run check",
"check:fix": "lerna run check:fix",
"test": "lerna run test",
"special:clean": "rm -rf node_modules .nx"
},
"devDependencies": {
Expand Down
12 changes: 10 additions & 2 deletions packages/ds-react-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@
"check:biome": "biome check src *.json",
"check:biome:fix": "biome check --write src *.json",
"check:ts": "tsc --noEmit",
"storybook": "storybook dev -p 6006 --no-open --host 0.0.0.0"
"storybook": "storybook dev -p 6006 --no-open --host 0.0.0.0",
"test": "bun run test:vitest",
"test:watch": "bun run test:vitest:watch",
"test:vitest": "vitest run",
"test:vitest:watch": "vitest"
},
"dependencies": {
"@canonical/storybook-addon-baseline-grid": "^0.5.1-experimental.0",
Expand All @@ -51,15 +55,19 @@
"@storybook/react": "^8.4.7",
"@storybook/react-vite": "^8.4.7",
"@storybook/test": "^8.4.7",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/node": "^22.10.1",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"copyfiles": "^2.4.1",
"globals": "^15.13.0",
"jsdom": "^25.0.1",
"storybook": "^8.4.7",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"vite-tsconfig-paths": "^5.1.4"
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.8"
}
}
15 changes: 15 additions & 0 deletions packages/ds-react-core/src/ui/Button/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import Component from "./Button.js";

describe("Button component", () => {
it("renders", () => {
render(<Component label={"Hello world!"} />);
expect(screen.getByText("Hello world!")).toBeInTheDocument();
});

it("applies className", () => {
render(<Component label={"Hello world!"} className="test-class" />);
expect(screen.getByText("Hello world!")).toHaveClass("test-class");
});
});
44 changes: 44 additions & 0 deletions packages/ds-react-core/src/ui/Chip/Chip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import Component from "./Chip.js";

describe("Chip component", () => {
it("renders", () => {
render(<Component lead={"Cloud"} value={"AWS"} />);
expect(screen.getByText("AWS")).toBeInTheDocument();
});

it("applies lead & value", () => {
render(<Component lead={"Cloud"} value={"AWS"} />);
const leadElement = screen.getByText("Cloud");
const valueElement = screen.getByText("AWS");

const chipElement = leadElement.closest(".ds.chip");
expect(chipElement).toBeInTheDocument();
expect(chipElement).toContainElement(leadElement);
expect(chipElement).toContainElement(valueElement);
});

it("applies positive appearance", () => {
render(<Component lead="Cloud" value="AWS" appearance="positive" />);

const leadElement = screen.getByText("Cloud");
const chipElement = leadElement.closest(".ds.chip");

expect(chipElement).toHaveClass("positive");
});

it("calls onClick", () => {
const onClick = vi.fn();
render(<Component lead={"Cloud"} value={"AWS"} onClick={onClick} />);
screen.getByText("AWS").click();
expect(onClick).toHaveBeenCalled();
});

it("calls onDismiss", () => {
const onDismiss = vi.fn();
render(<Component lead={"Cloud"} value={"AWS"} onDismiss={onDismiss} />);
screen.getByLabelText("Dismiss").click();
expect(onDismiss).toHaveBeenCalled();
});
});
14 changes: 12 additions & 2 deletions packages/ds-react-core/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@
"declarationDir": "dist/types",
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": false
"skipLibCheck": false,
"types": ["node", "react", "react-dom"]
advl marked this conversation as resolved.
Show resolved Hide resolved
},
"exclude": ["src/**/*.stories.ts", "src/**/*.stories.tsx", ".storybook"]
"exclude": [
"src/**/*.stories.ts",
"src/**/*.stories.tsx",
"src/**/*.test.ts",
"src/**/*.test.tsx",
"vite.config.ts",
"vitest.setup.ts",
"vitest.config.ts",
advl marked this conversation as resolved.
Show resolved Hide resolved
".storybook"
]
}
Loading
Loading