Skip to content

Jest custom matchers 🦨 #2046

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

Draft
wants to merge 31 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
247560b
scaffold jest matchers
TheSonOfThomp Oct 26, 2023
872b7cd
creates `createMatcher` script
TheSonOfThomp Oct 26, 2023
b2159ad
creates `toBeLabelled` matcher
TheSonOfThomp Oct 26, 2023
34f991e
updates creator function
TheSonOfThomp Oct 26, 2023
ad0b511
Update createNewMatcher.ts
TheSonOfThomp Oct 26, 2023
18e4145
creates `toSpreadRest`
TheSonOfThomp Oct 26, 2023
7e6a1aa
Update package.json
TheSonOfThomp Oct 26, 2023
259e70a
updates dependency validation checker
TheSonOfThomp Oct 26, 2023
33692c7
Create rude-scissors-deliver.md
TheSonOfThomp Oct 26, 2023
ecd21b0
Updates directory checking logic
TheSonOfThomp Oct 26, 2023
ea79e95
fix builds
TheSonOfThomp Oct 27, 2023
488d168
lint fix
TheSonOfThomp Oct 27, 2023
881ee24
fix dependencies
TheSonOfThomp Oct 30, 2023
73241ed
Update yarn.lock
TheSonOfThomp Oct 30, 2023
007a08e
Update toBeLabelled.spec.ts
TheSonOfThomp Oct 30, 2023
fa142c1
Update config.ts
TheSonOfThomp Oct 30, 2023
069d681
Update validateListedDevDependencies.ts
TheSonOfThomp Oct 30, 2023
1bc1c50
Merge branch 'main' into adam/jest-matchers
TheSonOfThomp Oct 31, 2023
39c7ab5
Merge branch 'main' into adam/fix-validation
TheSonOfThomp Oct 31, 2023
7835727
Update config.ts
TheSonOfThomp Oct 31, 2023
a051697
lint fix
TheSonOfThomp Oct 31, 2023
138d652
Merge branch 'main' into adam/fix-validation
bruugey Oct 31, 2023
a1cc0e6
Merge branch 'main' into adam/fix-validation
bruugey Oct 31, 2023
7de005b
Merge branch 'main' into adam/jest-matchers
TheSonOfThomp Nov 1, 2023
0be084e
Merge branch 'adam/fix-validation' into adam/jest-matchers
TheSonOfThomp Nov 1, 2023
2d05099
Merge branch 'main' into adam/jest-matchers
TheSonOfThomp Nov 27, 2023
2b18932
Merge branch 'main' into adam/jest-matchers
TheSonOfThomp Jan 10, 2024
8f1fac0
Merge branch 'main' into adam/jest-matchers
TheSonOfThomp Feb 15, 2024
2e2bfae
Update toSpreadRest.tsx
TheSonOfThomp Feb 15, 2024
cf8994c
Adds toSpreadRest to Combobox & DatePicker
TheSonOfThomp Feb 15, 2024
7917785
adds toBeLabelled to text input
TheSonOfThomp Feb 15, 2024
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
19 changes: 19 additions & 0 deletions tools/jest-matchers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Jest Matchers

![npm (scoped)](https://img.shields.io/npm/v/@leafygreen-ui/jest-matchers.svg)

#### [View on MongoDB.design](https://www.mongodb.design/component/jest-matchers/example/)

## Installation

### Yarn

```shell
yarn add @leafygreen-ui/jest-matchers
```

### NPM

```shell
npm install @leafygreen-ui/jest-matchers
```
40 changes: 40 additions & 0 deletions tools/jest-matchers/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@lg-tools/jest-matchers",
"version": "0.0.1",
"description": "LeafyGreen UI Kit Jest Matchers",
"main": "./dist/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"license": "Apache-2.0",
"scripts": {
"build": "lg-internal-build-package",
"tsc": "tsc --build tsconfig.json",
"create-matcher": "npx ts-node ./scripts/createNewMatcher.ts"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"chalk": "4.1.2",
"lodash": "^4.17.21"
},
"devDependencies": {
"@lg-tools/build": "0.3.0",
"commander": "^11.1.0",
"fs-extra": "^11.1.1",
"jsdom": "^22.1.0"
},
"peerDependencies": {
"@testing-library/react": "^14.0.0",
"@types/jest": "^29.5.0",
"jest": "^29.6.0"
},
"homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/jest-matchers",
"repository": {
"type": "git",
"url": "https://github.com/mongodb/leafygreen-ui"
},
"bugs": {
"url": "https://jira.mongodb.org/projects/PD/summary"
}
}
60 changes: 60 additions & 0 deletions tools/jest-matchers/scripts/createNewMatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import chalk from 'chalk';
import { Command } from 'commander';
import fse from 'fs-extra';
import { camelCase, lowerFirst } from 'lodash';
import path from 'path';

const cli = new Command();
cli
.argument('<matcher-name>', 'The name of the new matcher')
.action(createNewMatcher)
.parse();

function createNewMatcher(matcherName: string) {
matcherName = lowerFirst(camelCase(matcherName));

if (!matcherName.startsWith('to')) {
console.warn(
chalk.yellow(
`Matcher names should start with the word "to". Received \`${matcherName}\``,
),
);
}

const matchersDir = path.resolve(__dirname, '../src/matchers');

/** Create the matcher file */
const matchersFilePath = path.resolve(matchersDir, matcherName + '.ts');
const matcherFileTemplate = `
import { createMatcher } from '../utils/createMatcher';

export const ${matcherName} = createMatcher(function _${matcherName}() {
return {
pass: true,
message: () => '',
};
});
`;
fse.writeFileSync(matchersFilePath, matcherFileTemplate);

/** Create the test file */

const testFilePath = path.resolve(
__dirname,
'../src/tests',
matcherName + '.spec.ts',
);

const testFileTemplate = `
describe('tools/jest-matchers/.${matcherName}', () => {
test.todo('')
})
`;
fse.writeFileSync(testFilePath, testFileTemplate);

/** Update the matchers index file */
const indexFilePath = path.resolve(matchersDir, 'index.ts');
let indexContents = fse.readFileSync(indexFilePath, 'utf-8');
indexContents += `export { ${matcherName} } from './${matcherName}';\n`;
fse.writeFileSync(indexFilePath, indexContents);
}
21 changes: 21 additions & 0 deletions tools/jest-matchers/scripts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "CommonJS",
"noEmit": true,
"tsBuildInfoFile": "./tsconfig.tsbuildinfo",
"incremental": true,
"target": "ES5",
"jsx": "react",
"allowJs": true,
"pretty": true,
"strictNullChecks": true,
"noUnusedLocals": false,
"esModuleInterop": true,
"strict": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"baseUrl": ".",
"skipLibCheck": true,
"resolveJsonModule": true,
}
}
3 changes: 3 additions & 0 deletions tools/jest-matchers/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import * as matchers from './matchers';

expect.extend(matchers);
2 changes: 2 additions & 0 deletions tools/jest-matchers/src/matchers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { toBeLabelled } from './toBeLabelled';
export { toSpreadRest } from './toSpreadRest';
57 changes: 57 additions & 0 deletions tools/jest-matchers/src/matchers/toBeLabelled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { isNull } from 'lodash';

import { createMatcher } from '../utils/createMatcher';

const isValidString = (str: any): str is string =>
str && typeof str === 'string' && str.length > 0;

/**
* Returns whether the provided element has at least one of the following:
* - a `label` attribute
* - an `aria-label` attribute
* - an `aria-labelledby` attribute with a valid associated element
* - an associated element `<label>` element with the appropriate `for` attribute
*/
export const toBeLabelled = createMatcher(function _toBeLabelled(
element: Element,
) {
const label = element.getAttribute('label');
const ariaLabel = element.getAttribute('aria-label');
const ariaLabelledBy = element.getAttribute('aria-labelledby');
const elementId = element.getAttribute('id');

// If at least one...
const hasStaticLabel = [label, ariaLabel].some(isValidString);

const hasLabelReference = isValidString(ariaLabelledBy);
const doesAriaLabelledByReferenceExist =
hasLabelReference && !isNull(document.querySelector(`#${ariaLabelledBy}`));
const hasLabelForId =
elementId && !isNull(document.querySelector(`label[for="${elementId}"]`));

const pass = Boolean(
hasStaticLabel || doesAriaLabelledByReferenceExist || hasLabelForId,
);

const message = () => {
if (!pass && hasLabelReference) {
return this.isNot
? `Expected to not find element with id ${ariaLabelledBy}`
: `Could not find element referenced by \`aria-labelledby\`` +
document.body.innerHTML;
}

if (!pass && elementId) {
return this.isNot
? `Expected not to find label for ${elementId}`
: `Could not find a label for the element with id ${elementId}`;
}

return `Expected the element ${this.isNot ? 'not ' : ''}to have a label`;
};

return {
pass,
message,
};
});
45 changes: 45 additions & 0 deletions tools/jest-matchers/src/matchers/toSpreadRest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
import { render } from '@testing-library/react';
import chalk from 'chalk';

import { createMatcher } from '../utils/createMatcher';

/**
* Tests whether a Component
*/
export const toSpreadRest = createMatcher(function _toSpreadRest(
Component: React.ReactElement,
) {
const rest = {
'data-rest': 'value',
};

// @ts-expect-error Type of Component props is unknown
const renderResult = render(<Component {...rest} />);
const componentElement = renderResult.container.firstElementChild;

const renderedAttributeKeys = componentElement?.getAttributeNames();

const areAllRestPropsRendered = Object.keys(rest).every(prop =>
renderedAttributeKeys?.includes(prop),
);

const areAllAttributeValuesCorrect =
areAllRestPropsRendered &&
Object.entries(rest).every(restProp => {
const attr = componentElement?.getAttribute(restProp[0]);
return attr === restProp[1];
});

const pass = areAllAttributeValuesCorrect;

return {
pass,
message: () =>
`Expected component to ${this.isNot ? 'not ' : ''}spread rest.\n` +
chalk.bold.green(
`Expected attributes:\n${Object.keys(rest).join(', ')}\n\n`,
) +
chalk.bold.red(`Received props:\n${renderedAttributeKeys?.join(', ')}`),
};
});
48 changes: 48 additions & 0 deletions tools/jest-matchers/src/tests/toBeLabelled.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { render } from './utils/testutils';

describe('tools/jest-matchers/.toBeLabelled', () => {
test('unlabelled', () => {
const { queryByTestId } = render(`
<input type="text" data-testid="unlabelled" />
`);
expect(queryByTestId('unlabelled')).not.toBeLabelled();
});

test('`label` attribute', () => {
const { queryByTestId } = render(`
<input label="label" type="text" data-testid="labelled" />
`);

expect(queryByTestId('labelled')).toBeLabelled();
});

test('`aria-label` attribute', () => {
const { queryByTestId } = render(`
<input aria-label="label" type="text" data-testid="labelled" />
`);

expect(queryByTestId('labelled')).toBeLabelled();
});

test('`aria-labelledby` attribute', () => {
const { queryByTestId } = render(`
<label id="label-id">Text</label>
<input aria-labelledby="label-id" type="text" data-testid="labelled" />
<input aria-labelledby="invalid" type="text" data-testid="unlabelled" />
`);

expect(queryByTestId('labelled')).toBeLabelled();
expect(queryByTestId('unlabelled')).not.toBeLabelled();
});

test('`for` attribute', () => {
const { queryByTestId } = render(`
<label for="input-id">Text</label>
<input id="input-id" type="text" data-testid="labelled" />
<input id="invalid" type="text" data-testid="unlabelled" />
`);

expect(queryByTestId('labelled')).toBeLabelled();
expect(queryByTestId('unlabelled')).not.toBeLabelled();
});
});
23 changes: 23 additions & 0 deletions tools/jest-matchers/src/tests/toSpreadRest.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';

describe('tools/jest-matchers/.toSpreadRest', () => {
test('spreads rest', () => {
const Component = (props: React.ComponentProps<'div'>) => {
return (
<div id="some-id" {...props}>
Children
</div>
);
};

expect(Component).toSpreadRest();
});

test('does not spread rest', () => {
const Component = (props: React.ComponentProps<'div'>) => {
return <div id="some-id">Children</div>;
};

expect(Component).not.toSpreadRest();
});
});
29 changes: 29 additions & 0 deletions tools/jest-matchers/src/tests/utils/testutils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const document = (() => {
if (global.document) {
return global.document;
} else {
const { JSDOM } = require('jsdom');
const { window } = new JSDOM();

return window.document;
}
})();

function render(html: string) {
const container = document.createElement('div');
container.innerHTML = html;
const queryByTestId = (testId: string) =>
container.querySelector(`[data-testid="${testId}"]`);
// asFragment has been stolen from react-testing-library
const asFragment = () =>
document.createRange().createContextualFragment(container.innerHTML);

// Some tests need to look up global ids with document.getElementById()
// so we need to be inside an actual document.
document.body.innerHTML = '';
document.body.appendChild(container);

return { container, queryByTestId, asFragment };
}

export { render };
5 changes: 5 additions & 0 deletions tools/jest-matchers/src/utils/createMatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const createMatcher = (
matcher: jest.CustomMatcher,
): jest.CustomMatcher => {
return matcher;
};
14 changes: 14 additions & 0 deletions tools/jest-matchers/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"extends": "@lg-tools/build/config/package.tsconfig.json",
"compilerOptions": {
"declarationDir": "dist",
"outDir": "dist",
"rootDir": "src",
"baseUrl": ".",
"noUnusedLocals": false
},
"include": [
"src/**/*"
],
"exclude": ["**/*.spec.*", "**/*.story.*", "src/tests"],
}
1 change: 1 addition & 0 deletions tools/test/config/common.setup.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require('@testing-library/jest-dom');
require('@lg-tools/jest-matchers');

const { toHaveNoViolations } = require('jest-axe');
expect.extend(toHaveNoViolations);
1 change: 1 addition & 0 deletions tools/test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@emotion/react": "11.11.1",
"@emotion/server": "11.11.0",
"@lg-tools/build": "0.3.1",
"@lg-tools/jest-matchers": "0.0.1",
"@lg-tools/meta": "0.1.5",
"@testing-library/dom": "9.3.1",
"@testing-library/jest-dom": "5.17.0",
Expand Down
Loading