diff --git a/.changeset/plenty-students-check.md b/.changeset/plenty-students-check.md new file mode 100644 index 0000000000..b83199f9e0 --- /dev/null +++ b/.changeset/plenty-students-check.md @@ -0,0 +1,21 @@ +--- +'@backstage/core-components': patch +--- + +Adds the ability to mock a media query per break point and to change the active break point during a test. Usage example: + +```ts +const { set } = mockBreakpoint({ + initialBreakpoint: 'md', + queryBreakpointMap: { + '(min-width:1500px)': 'xl', + '(min-width:1000px)': 'lg', + '(min-width:700px)': 'md', + '(min-width:400px)': 'sm', + '(min-width:0px)': 'xs', + }, +}); +// assertions for when the active break point is "md" +set('lg'); +// assertions for when the active break point is "lg" +``` diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 40b31f5a8d..b8d01bd58e 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -65,6 +65,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@react-hookz/web": "^24.0.0", + "@testing-library/react": "^16.0.0", "@types/react-sparklines": "^1.7.0", "ansi-regex": "^6.0.1", "classnames": "^2.2.6", @@ -98,7 +99,6 @@ "@backstage/test-utils": "workspace:^", "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", "@types/ansi-regex": "^5.0.0", "@types/classnames": "^2.2.9", diff --git a/packages/core-components/report-testUtils.api.md b/packages/core-components/report-testUtils.api.md index 93a6bb2a07..0cb4282243 100644 --- a/packages/core-components/report-testUtils.api.md +++ b/packages/core-components/report-testUtils.api.md @@ -3,8 +3,19 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { Breakpoint } from '@material-ui/core/styles/createBreakpoints'; + // @public export function mockBreakpoint(options: { matches: boolean }): void; +// @public +export function mockBreakpoint(options: { + initialBreakpoint?: Breakpoint; + queryBreakpointMap?: Record; +}): { + set(value: string): void; + remove(): void; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/core-components/src/testUtils.test.ts b/packages/core-components/src/testUtils.test.ts new file mode 100644 index 0000000000..ca1979c5d4 --- /dev/null +++ b/packages/core-components/src/testUtils.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Breakpoint } from '@material-ui/core/styles/createBreakpoints'; +import { mockBreakpoint } from './testUtils'; + +describe('mockBreakpoint', () => { + const originalMatchMedia = window.matchMedia; + + afterAll(() => { + window.matchMedia = originalMatchMedia; + }); + + it('should remove the mock', () => { + const { remove } = mockBreakpoint({ initialBreakpoint: 'md' }); + expect(originalMatchMedia).not.toBe(window.matchMedia); + remove(); + expect(window.matchMedia).toBe(originalMatchMedia); + }); + + it('should mock matchMedia with initialBreakpoint', () => { + const { set } = mockBreakpoint({ initialBreakpoint: 'md' }); + + expect(window.matchMedia('(min-width:960px)').matches).toBe(true); + expect(window.matchMedia('(min-width:1280px)').matches).toBe(false); + + set('lg'); + expect(window.matchMedia('(min-width:1280px)').matches).toBe(true); + }); + + it('should mock matchMedia with matches option', () => { + mockBreakpoint({ matches: true }); + + expect(window.matchMedia('(min-width:1920px)').matches).toBe(true); + expect(window.matchMedia('(min-width:1280px)').matches).toBe(true); + expect(window.matchMedia('(min-width:960px)').matches).toBe(true); + expect(window.matchMedia('(min-width:600px)').matches).toBe(true); + expect(window.matchMedia('(min-width:0px)').matches).toBe(true); + }); + + it('should mock matchMedia with custom queryBreakpointMap', () => { + const customMap: Record = { + '(min-width:1500px)': 'xl', + '(min-width:1000px)': 'lg', + '(min-width:700px)': 'md', + '(min-width:400px)': 'sm', + '(min-width:0px)': 'xs', + }; + const { set } = mockBreakpoint({ + initialBreakpoint: 'sm', + queryBreakpointMap: customMap, + }); + + expect(window.matchMedia('(min-width:400px)').matches).toBe(true); + expect(window.matchMedia('(min-width:700px)').matches).toBe(false); + + set('md'); + expect(window.matchMedia('(min-width:700px)').matches).toBe(true); + }); +}); diff --git a/packages/core-components/src/testUtils.ts b/packages/core-components/src/testUtils.ts index 17bb0db63b..92c094743a 100644 --- a/packages/core-components/src/testUtils.ts +++ b/packages/core-components/src/testUtils.ts @@ -14,6 +14,11 @@ * limitations under the License. */ +import { Breakpoint } from '@material-ui/core/styles/createBreakpoints'; +import { act } from '@testing-library/react'; + +const originalMatchMedia = window.matchMedia; + /** * This is a mocking method suggested in the Jest docs, as it is not implemented in JSDOM yet. * It can be used to mock values for the Material UI `useMediaQuery` hook if it is used in a tested component. @@ -25,19 +30,137 @@ * https://mui.com/material-ui/react-use-media-query/#testing * * @public + * + * @example + * Match with any media query: + * ```ts + * mockBreakpoint({ matches: true }); + * ``` */ -export function mockBreakpoint(options: { matches: boolean }) { +export function mockBreakpoint(options: { matches: boolean }): void; +/** + * This is a mocking method suggested in the Jest docs, as it is not implemented in JSDOM yet. + * It can be used to mock values for the Material UI `useMediaQuery` hook if it is used in a tested component. + * + * For issues checkout the documentation: + * https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom + * + * If there are any updates from Material UI React on testing `useMediaQuery` this mock should be replaced + * https://mui.com/material-ui/react-use-media-query/#testing + * + * @public + * + * @example + * Set the initial breakpoint: + * ```ts + * mockBreakpoint({ initialBreakpoint: 'md' }); + * ``` + * + * @example + * Map media queries to breakpoints: + * ```ts + * mockBreakpoint({ queryBreakpointMap: { '(min-width:1500px)': 'xl', '(min-width:1000px)': 'lg', '(min-width:700px)': 'md', '(min-width:400px)': 'sm', '(min-width:0px)': 'xs', } }); + * ``` + * + * @example + * Change the breakpoint during the test: + * ```ts + * const { set } = mockBreakpoint({ initialBreakpoint: 'md' }); + * set('lg'); + * ``` + **/ +export function mockBreakpoint(options: { + /** Defaults to 'lg' */ + initialBreakpoint?: Breakpoint; + /** Defaults to \{ '(min-width:1920px)': 'xl', '(min-width:1280px)': 'lg', '(min-width:960px)': 'md', '(min-width:600px)': 'sm', '(min-width:0px)': 'xs' \} */ + queryBreakpointMap?: Record; +}): { + set(value: string): void; + remove(): void; +}; +export function mockBreakpoint( + options: + | { + matches: boolean; + } + | { + initialBreakpoint?: Breakpoint; + queryBreakpointMap?: Record; + }, +): { + set(value: string): void; + remove(): void; +} { + const mediaQueries: { + mediaQueryString: string; + mediaQueryList: { matches: boolean }; + mediaQueryListeners: Set<(event: { matches: boolean }) => void>; + }[] = []; + let breakpoint: Breakpoint = 'lg'; + if ('initialBreakpoint' in options && options.initialBreakpoint) { + breakpoint = options.initialBreakpoint; + } + const breakpoints: Record = + 'queryBreakpointMap' in options && + typeof options.queryBreakpointMap === 'object' + ? options.queryBreakpointMap + : { + '(min-width:1920px)': 'xl', + '(min-width:1280px)': 'lg', + '(min-width:960px)': 'md', + '(min-width:600px)': 'sm', + '(min-width:0px)': 'xs', + }; Object.defineProperty(window, 'matchMedia', { writable: true, - value: jest.fn().mockImplementation(query => ({ - matches: options.matches ?? false, - media: query, - onchange: null, - addListener: jest.fn(), // deprecated - removeListener: jest.fn(), // deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), + value: jest.fn().mockImplementation(mediaQueryString => { + const mediaQueryListeners = new Set< + (event: { matches: boolean }) => void + >(); + const mediaQueryList = { + matches: + 'matches' in options + ? options.matches + : breakpoints[mediaQueryString] === breakpoint, + media: mediaQueryString, + onchange: null, + addListener: jest.fn(listener => { + mediaQueryListeners.add(listener); + }), + removeListener: jest.fn(listener => { + mediaQueryListeners.delete(listener); + }), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + }; + mediaQueries.push({ + mediaQueryString, + mediaQueryList, + mediaQueryListeners, + }); + return mediaQueryList; + }), }); + + return { + set(newBreakpoint: Breakpoint) { + breakpoint = newBreakpoint; + mediaQueries.forEach( + ({ mediaQueryString, mediaQueryList, mediaQueryListeners }) => { + act(() => { + const matches = + 'matches' in options + ? options.matches + : breakpoints[mediaQueryString] === breakpoint; + mediaQueryList.matches = matches; + mediaQueryListeners.forEach(listener => listener({ matches })); + }); + }, + ); + }, + remove() { + window.matchMedia = originalMatchMedia; + }, + }; }