Frontend: Move toEmit jest matchers to shared workspace (#108610)

* feat(test-utils): move the toEmitValue/s jest matchers to test-utils and add test script

* chore(jest): update configs to use matchers from test-utils package

* ci(frontend-tests): hook up packages tests

* fix(test-utils): re-export matchers from index.ts so packages that include setupTests don't error

* ci(pr-frontend-unit-tests): add frontend-packages-unit-tests to list of required unit tests

* Update packages/grafana-test-utils/README.md

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>

---------

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>
This commit is contained in:
Jack Westbrook
2025-07-30 14:39:44 +02:00
committed by GitHub
parent afdb3d7c95
commit b707cd28f1
28 changed files with 137 additions and 26 deletions

View File

@ -107,6 +107,24 @@ jobs:
- run: yarn install --immutable --check-cache
- run: yarn run plugin:test:ci
frontend-packages-unit-tests:
needs: detect-changes
if: needs.detect-changes.outputs.changed == 'true'
runs-on: ubuntu-latest-8-cores
name: "Packages unit tests"
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- run: yarn install --immutable --check-cache
- run: yarn run packages:test:ci
# This is the job that is actually required by rulesets.
# We need to require EITHER the OSS or the Enterprise job to pass.
# However, if one is skipped, GitHub won't flat-map the shards,
@ -116,6 +134,7 @@ jobs:
- frontend-unit-tests
- frontend-unit-tests-enterprise
- frontend-decoupled-plugin-tests
- frontend-packages-unit-tests
# always() is the best function here.
# success() || failure() will skip this function if any need is also skipped.
# That means conditional test suites will fail the entire requirement check.

View File

@ -43,6 +43,7 @@
"packages:prepare": "lerna version --no-push --no-git-tag-version --force-publish --exact",
"packages:pack": "mkdir -p ./npm-artifacts && lerna exec --no-private -- yarn pack --out \"../../npm-artifacts/%s-%v.tgz\"",
"packages:typecheck": "nx run-many -t typecheck --projects='tag:scope:package'",
"packages:test:ci": "nx run-many -t test:ci --projects='tag:scope:package'",
"prettier:check": "prettier --check --ignore-path .prettierignore --list-different=false --log-level=warn \"**/*.{ts,tsx,scss,md,mdx,json,js,cjs}\"",
"prettier:checkDocs": "prettier --check --list-different=false --log-level=warn \"docs/**/*.md\" \"*.md\" \"packages/**/*.{ts,tsx,scss,md,mdx,json,js,cjs}\"",
"prettier:write": "prettier --ignore-path .prettierignore --list-different \"**/*.{js,ts,tsx,scss,md,mdx,json,cjs}\" --write",

View File

@ -1,6 +1,10 @@
import '@testing-library/jest-dom';
import { TextEncoder, TextDecoder } from 'util';
import { matchers } from '@grafana/test-utils/matchers';
expect.extend(matchers);
Object.assign(global, { TextDecoder, TextEncoder });
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom

View File

@ -1,3 +1,24 @@
# Grafana test utils
This package is a collection of test utils and a mock API (using MSW) for use with core Grafana UI development.
## Matchers
To add the matchers to your Jest config, import them then extend `expect`. This should be done in the `setupFilesAfterEnv` file declared in `jest.config.{js,ts}`.
```ts
// setupTests.ts
import { matchers } from '@grafana/test-utils';
expect.extend(matchers);
```
Included in this package are the following matchers:
### `toEmitValues`
Tests that an Observable emits the expected values in the correct order. This matcher collects all emitted values (including errors) and compares them against the expected array using deep equality.
### `toEmitValuesWith`
Tests that an Observable emits values that satisfy custom expectations. This matcher collects all emitted values and passes them to a callback function where you can perform custom assertions.

View File

@ -0,0 +1,3 @@
import { matchers } from '@grafana/test-utils/matchers';
expect.extend(matchers);

View File

@ -0,0 +1,29 @@
process.env.TZ = 'Pacific/Easter'; // UTC-06:00 or UTC-05:00 depending on daylight savings
export default {
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
setupFilesAfterEnv: ['<rootDir>/jest-setup.js'],
testMatch: ['<rootDir>/**/__tests__/**/*.{js,jsx,ts,tsx}', '<rootDir>/**/*.{spec,test,jest}.{js,jsx,ts,tsx}'],
testEnvironment: 'node',
transform: {
'^.+\\.(t|j)sx?$': [
'@swc/jest',
{
sourceMaps: 'inline',
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
decorators: false,
dynamicImport: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
},
},
],
},
};

View File

@ -40,17 +40,28 @@
"./unstable": {
"import": "./src/unstable.ts",
"require": "./src/unstable.ts"
},
"./matchers": {
"types": "./src/matchers/index.ts",
"import": "./src/matchers/index.ts",
"require": "./src/matchers/index.ts"
}
},
"scripts": {
"typecheck": "tsc --emitDeclarationOnly false --noEmit"
"typecheck": "tsc --emitDeclarationOnly false --noEmit",
"test": "jest --watch --onlyChanged",
"test:ci": "jest --maxWorkers 4"
},
"dependencies": {
"msw": "2.10.4"
},
"devDependencies": {
"@grafana/tsconfig": "^2.0.0",
"@swc/core": "1.10.12",
"@swc/jest": "^0.2.26",
"@types/jest": "29.5.14",
"@types/node": "22.16.5",
"jest": "29.7.0",
"typescript": "5.8.3"
}
}

View File

@ -5,4 +5,6 @@
* @packageDocumentation
*/
export {};
// This is also exported as `@grafana/test-utils/matchers` but we cannot use that in places
// where the tsconfig is not set to moduleResolution: bundler so we export it here also.
export { matchers } from './matchers';

View File

@ -1,4 +1,4 @@
import { Observable } from 'rxjs';
import type { Observable } from 'rxjs';
import { toEmitValues } from './toEmitValues';
import { toEmitValuesWith } from './toEmitValuesWith';

View File

@ -0,0 +1,28 @@
import { Observable } from 'rxjs';
export const OBSERVABLE_TEST_TIMEOUT_IN_MS = 1000;
export interface ObservableMatchers<R, T = {}> extends jest.ExpectExtendMap {
toEmitValues<T>(received: Observable<T>, expected: T[]): Promise<jest.CustomMatcherResult>;
toEmitValuesWith<T>(
received: Observable<T>,
expectations: (received: T[]) => void
): Promise<jest.CustomMatcherResult>;
}
type ObservableType<T> = T extends Observable<infer V> ? V : never;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R, T = {}> {
toEmitValues<E = ObservableType<T>>(expected: E[]): Promise<CustomMatcherResult>;
/**
* Collect all the values emitted by the observables (also errors) and pass them to the expectations functions after
* the observable ended (or emitted error). If Observable does not complete within OBSERVABLE_TEST_TIMEOUT_IN_MS the
* test fails.
*/
toEmitValuesWith<E = ObservableType<T>>(expectations: (received: E[]) => void): Promise<CustomMatcherResult>;
}
}
}

View File

@ -1 +1 @@
import '@grafana/plugin-configs/jest/jest-setup.js';
import '@grafana/plugin-configs/jest/jest-setup';

View File

@ -1 +1 @@
import '@grafana/plugin-configs/jest/jest-setup.js';
import '@grafana/plugin-configs/jest/jest-setup';

View File

@ -1 +1 @@
import '@grafana/plugin-configs/jest/jest-setup.js';
import '@grafana/plugin-configs/jest/jest-setup';

View File

@ -1 +1 @@
import '@grafana/plugin-configs/jest/jest-setup.js';
import '@grafana/plugin-configs/jest/jest-setup';

View File

@ -1 +1 @@
import '@grafana/plugin-configs/jest/jest-setup.js';
import '@grafana/plugin-configs/jest/jest-setup';

View File

@ -1 +1 @@
import '@grafana/plugin-configs/jest/jest-setup.js';
import '@grafana/plugin-configs/jest/jest-setup';

View File

@ -1 +1 @@
import '@grafana/plugin-configs/jest/jest-setup.js';
import '@grafana/plugin-configs/jest/jest-setup';

View File

@ -1 +1 @@
import '@grafana/plugin-configs/jest/jest-setup.js';
import '@grafana/plugin-configs/jest/jest-setup';

View File

@ -1 +1 @@
import '@grafana/plugin-configs/jest/jest-setup.js';
import '@grafana/plugin-configs/jest/jest-setup';

View File

@ -1 +1 @@
import '@grafana/plugin-configs/jest/jest-setup.js';
import '@grafana/plugin-configs/jest/jest-setup';

View File

@ -1,11 +0,0 @@
import { Observable } from 'rxjs';
export const OBSERVABLE_TEST_TIMEOUT_IN_MS = 1000;
export interface ObservableMatchers<R, T = {}> extends jest.ExpectExtendMap {
toEmitValues<T>(received: Observable<T>, expected: T[]): Promise<jest.CustomMatcherResult>;
toEmitValuesWith<T>(
received: Observable<T>,
expectations: (received: T[]) => void
): Promise<jest.CustomMatcherResult>;
}

View File

@ -7,9 +7,9 @@ import i18next from 'i18next';
import failOnConsole from 'jest-fail-on-console';
import { initReactI18next } from 'react-i18next';
import getEnvConfig from '../../scripts/webpack/env-util';
import { matchers } from '@grafana/test-utils';
import { matchers } from './matchers';
import getEnvConfig from '../../scripts/webpack/env-util';
const config = getEnvConfig() as Record<string, string | boolean>;

View File

@ -3714,7 +3714,11 @@ __metadata:
resolution: "@grafana/test-utils@workspace:packages/grafana-test-utils"
dependencies:
"@grafana/tsconfig": "npm:^2.0.0"
"@swc/core": "npm:1.10.12"
"@swc/jest": "npm:^0.2.26"
"@types/jest": "npm:29.5.14"
"@types/node": "npm:22.16.5"
jest: "npm:29.7.0"
msw: "npm:2.10.4"
typescript: "npm:5.8.3"
languageName: unknown