diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6e3c1905..63f88b99 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 97f5ada4..c5c593c6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,4 +15,7 @@ jobs: uses: ./.github/actions/setup-env - name: Code quality checks - run: bun run check \ No newline at end of file + run: bun run check + + - name: Test + run: bun run test \ No newline at end of file diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index d7698174..be486ead 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -20,3 +20,6 @@ jobs: - name: Code quality checks run: bun run check + + - name: Test + run: bun run test diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 6318656a..f9d11ad2 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -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 diff --git a/README.md b/README.md index 4eeec0cd..e6211111 100644 --- a/README.md +++ b/README.md @@ -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. 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. @@ -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. 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. diff --git a/bun.lockb b/bun.lockb index 751705b5..d3804954 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/guides/TESTING.md b/guides/TESTING.md new file mode 100644 index 00000000..cb34d72f --- /dev/null +++ b/guides/TESTING.md @@ -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. + +### 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 `. + +#### 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(); + expect(screen.getByText("AWS")).toBeInTheDocument(); + }); + + // Test passing in props and nesting elements + it("applies lead & value", () => { + render(); + 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(); + + 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(); + screen.getByText("AWS").click(); + expect(onClick).toHaveBeenCalled(); + }); +}); +``` diff --git a/nx.json b/nx.json index 458c7a4c..d52da8b3 100644 --- a/nx.json +++ b/nx.json @@ -14,6 +14,10 @@ "check:fix": { "dependsOn": ["^check:fix"], "cache": true + }, + "test": { + "dependsOn": ["^build"], + "cache": true } } } diff --git a/package.json b/package.json index b25a7f0b..aa669106 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/ds-react-core/package.json b/packages/ds-react-core/package.json index aaf8773b..e782e306 100644 --- a/packages/ds-react-core/package.json +++ b/packages/ds-react-core/package.json @@ -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", @@ -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" } } diff --git a/packages/ds-react-core/src/ui/Button/Button.test.tsx b/packages/ds-react-core/src/ui/Button/Button.test.tsx new file mode 100644 index 00000000..8c3874e6 --- /dev/null +++ b/packages/ds-react-core/src/ui/Button/Button.test.tsx @@ -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(); + expect(screen.getByText("Hello world!")).toBeInTheDocument(); + }); + + it("applies className", () => { + render(); + expect(screen.getByText("Hello world!")).toHaveClass("test-class"); + }); +}); diff --git a/packages/ds-react-core/src/ui/Chip/Chip.test.tsx b/packages/ds-react-core/src/ui/Chip/Chip.test.tsx new file mode 100644 index 00000000..a1e9af7a --- /dev/null +++ b/packages/ds-react-core/src/ui/Chip/Chip.test.tsx @@ -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(); + expect(screen.getByText("AWS")).toBeInTheDocument(); + }); + + it("applies lead & value", () => { + render(); + 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(); + + const leadElement = screen.getByText("Cloud"); + const chipElement = leadElement.closest(".ds.chip"); + + expect(chipElement).toHaveClass("positive"); + }); + + it("calls onClick", () => { + const onClick = vi.fn(); + render(); + screen.getByText("AWS").click(); + expect(onClick).toHaveBeenCalled(); + }); + + it("calls onDismiss", () => { + const onDismiss = vi.fn(); + render(); + screen.getByLabelText("Dismiss").click(); + expect(onDismiss).toHaveBeenCalled(); + }); +}); diff --git a/packages/ds-react-core/tsconfig.build.json b/packages/ds-react-core/tsconfig.build.json index b6dbf563..392141bc 100644 --- a/packages/ds-react-core/tsconfig.build.json +++ b/packages/ds-react-core/tsconfig.build.json @@ -7,7 +7,17 @@ "declarationDir": "dist/types", "declarationMap": true, "sourceMap": true, - "skipLibCheck": false + "skipLibCheck": false, + "types": ["node", "react", "react-dom"] }, - "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", + ".storybook" + ] } diff --git a/packages/ds-react-core/tsconfig.json b/packages/ds-react-core/tsconfig.json index c3cc2e65..a40c452b 100644 --- a/packages/ds-react-core/tsconfig.json +++ b/packages/ds-react-core/tsconfig.json @@ -3,12 +3,21 @@ "compilerOptions": { "baseUrl": "src", "skipLibCheck": true, - "types": ["node", "react", "react-dom"] + "types": [ + "node", + "react", + "react-dom", + "vitest/globals", + "@testing-library/jest-dom" + ] }, "include": [ "src/**/*.ts", "src/**/*.tsx", ".storybook/*.ts", - ".storybook/*.tsx" + ".storybook/*.tsx", + "vite.config.ts", + "vitest.setup.ts", + "vitest.config.ts" ] } diff --git a/packages/ds-react-core/vite.config.ts b/packages/ds-react-core/vite.config.ts index 88ec4964..a7932132 100644 --- a/packages/ds-react-core/vite.config.ts +++ b/packages/ds-react-core/vite.config.ts @@ -1,8 +1,12 @@ -import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react(), tsconfigPaths()], + build: { + // include sourcemaps for easier debugging + sourcemap: true, + }, }); diff --git a/packages/ds-react-core/vitest.config.ts b/packages/ds-react-core/vitest.config.ts new file mode 100644 index 00000000..151aa3c3 --- /dev/null +++ b/packages/ds-react-core/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import viteConfig from "./vite.config.js"; + +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, + // Defines files that perform extra vitest configuration + // Currently, this is used to extend vitest matchers and cleanup the DOM after each test + setupFiles: ["./vitest.setup.ts"], + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + }, + }), +); diff --git a/packages/ds-react-core/vitest.setup.ts b/packages/ds-react-core/vitest.setup.ts new file mode 100644 index 00000000..5e863099 --- /dev/null +++ b/packages/ds-react-core/vitest.setup.ts @@ -0,0 +1,9 @@ +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(); +}); diff --git a/packages/generator-ds/src/component/templates/Component.test.tsx.ejs b/packages/generator-ds/src/component/templates/Component.test.tsx.ejs index b0ddf0c3..11f9b404 100644 --- a/packages/generator-ds/src/component/templates/Component.test.tsx.ejs +++ b/packages/generator-ds/src/component/templates/Component.test.tsx.ejs @@ -7,11 +7,11 @@ import Component from "./<%= componentName %>.js"; describe("<%= componentName %> component", () => { it("renders", () => { render(<%= componentName %>); - expect(screen.getByText('<%= componentName %>')).toBeDefined(); + expect(screen.getByText('<%= componentName %>')).toBeInTheDocument(); }); it("applies className", () => { render(<%= componentName %>); - expect(screen.getByText("<%= componentName %>").classList).toContain("test-class"); + expect(screen.getByText("<%= componentName %>")).toHaveClass("test-class"); }); }); \ No newline at end of file