Skip to content

Commit 1b14efd

Browse files
caponettoBhakti Narvekar
authored andcommitted
test: add unit tests for frontend hooks (kubeflow#527)
Signed-off-by: Guilherme Caponetto <[email protected]>
1 parent a932c0f commit 1b14efd

12 files changed

+774
-68
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React from 'react';
2+
import { MemoryRouter } from 'react-router-dom';
3+
import { renderHook } from '~/__tests__/unit/testUtils/hooks';
4+
import { useCurrentRouteKey } from '~/app/hooks/useCurrentRouteKey';
5+
import { AppRouteKey, AppRoutePaths } from '~/app/routes';
6+
7+
describe('useCurrentRouteKey', () => {
8+
const wrapper: React.FC<React.PropsWithChildren<{ initialEntries: string[] }>> = ({
9+
children,
10+
initialEntries,
11+
}) => <MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>;
12+
13+
const fillParams = (pattern: string) => pattern.replace(/:([^/]+)/g, 'test');
14+
const cases: ReadonlyArray<readonly [string, AppRouteKey]> = (
15+
Object.entries(AppRoutePaths) as [AppRouteKey, string][]
16+
).map(([key, pattern]) => [fillParams(pattern), key]);
17+
18+
it.each(cases)('matches route keys by path: %s', (path, expected) => {
19+
const { result } = renderHook(() => useCurrentRouteKey(), {
20+
wrapper: (props) => wrapper({ ...props, initialEntries: [path] }),
21+
});
22+
expect(result.current).toBe(expected);
23+
});
24+
25+
it('returns undefined for unknown paths', () => {
26+
const { result } = renderHook(() => useCurrentRouteKey(), {
27+
wrapper: (props) => wrapper({ ...props, initialEntries: ['/unknown'] }),
28+
});
29+
expect(result.current).toBeUndefined();
30+
});
31+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { renderHook } from '~/__tests__/unit/testUtils/hooks';
2+
import useMount from '~/app/hooks/useMount';
3+
4+
describe('useMount', () => {
5+
it('invokes callback on mount', () => {
6+
const cb = jest.fn();
7+
renderHook(() => useMount(cb));
8+
expect(cb).toHaveBeenCalledTimes(1);
9+
});
10+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { renderHook } from '~/__tests__/unit/testUtils/hooks';
2+
import useNamespaces from '~/app/hooks/useNamespaces';
3+
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
4+
import { NotebookApis } from '~/shared/api/notebookApi';
5+
import { APIState } from '~/shared/api/types';
6+
7+
jest.mock('~/app/hooks/useNotebookAPI', () => ({
8+
useNotebookAPI: jest.fn(),
9+
}));
10+
11+
const mockUseNotebookAPI = useNotebookAPI as jest.MockedFunction<typeof useNotebookAPI>;
12+
13+
describe('useNamespaces', () => {
14+
beforeEach(() => {
15+
jest.clearAllMocks();
16+
});
17+
18+
it('rejects when API not available', async () => {
19+
const unavailableState: APIState<NotebookApis> = {
20+
apiAvailable: false,
21+
api: {} as NotebookApis,
22+
};
23+
mockUseNotebookAPI.mockReturnValue({ ...unavailableState, refreshAllAPI: jest.fn() });
24+
25+
const { result, waitForNextUpdate } = renderHook(() => useNamespaces());
26+
await waitForNextUpdate();
27+
28+
const [namespacesData, loaded, loadError] = result.current;
29+
expect(namespacesData).toBeNull();
30+
expect(loaded).toBe(false);
31+
expect(loadError).toBeDefined();
32+
});
33+
34+
it('returns data when API is available', async () => {
35+
const listNamespaces = jest.fn().mockResolvedValue({ ok: true, data: [{ name: 'ns1' }] });
36+
const api = { namespaces: { listNamespaces } } as unknown as NotebookApis;
37+
38+
const availableState: APIState<NotebookApis> = {
39+
apiAvailable: true,
40+
api,
41+
};
42+
mockUseNotebookAPI.mockReturnValue({ ...availableState, refreshAllAPI: jest.fn() });
43+
44+
const { result, waitForNextUpdate } = renderHook(() => useNamespaces());
45+
await waitForNextUpdate();
46+
47+
const [namespacesData, loaded, loadError] = result.current;
48+
expect(namespacesData).toEqual([{ name: 'ns1' }]);
49+
expect(loaded).toBe(true);
50+
expect(loadError).toBeUndefined();
51+
});
52+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React from 'react';
2+
import { renderHook } from '~/__tests__/unit/testUtils/hooks';
3+
import { NotebookContext } from '~/app/context/NotebookContext';
4+
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
5+
6+
jest.mock('~/app/EnsureAPIAvailability', () => ({
7+
default: ({ children }: { children?: React.ReactNode }) => children as React.ReactElement,
8+
}));
9+
10+
describe('useNotebookAPI', () => {
11+
it('returns api state and refresh function from context', () => {
12+
const refreshAPIState = jest.fn();
13+
const api = {} as ReturnType<typeof useNotebookAPI>['api'];
14+
const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
15+
<NotebookContext.Provider
16+
value={{
17+
apiState: { apiAvailable: true, api },
18+
refreshAPIState,
19+
}}
20+
>
21+
{children}
22+
</NotebookContext.Provider>
23+
);
24+
25+
const { result } = renderHook(() => useNotebookAPI(), { wrapper });
26+
27+
expect(result.current.apiAvailable).toBe(true);
28+
expect(result.current.api).toBe(api);
29+
30+
result.current.refreshAllAPI();
31+
expect(refreshAPIState).toHaveBeenCalled();
32+
});
33+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { renderHook } from '~/__tests__/unit/testUtils/hooks';
2+
import useWorkspaceFormData, { EMPTY_FORM_DATA } from '~/app/hooks/useWorkspaceFormData';
3+
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
4+
import { NotebookApis } from '~/shared/api/notebookApi';
5+
import { buildMockWorkspace, buildMockWorkspaceKind } from '~/shared/mock/mockBuilder';
6+
7+
jest.mock('~/app/hooks/useNotebookAPI', () => ({
8+
useNotebookAPI: jest.fn(),
9+
}));
10+
11+
const mockUseNotebookAPI = useNotebookAPI as jest.MockedFunction<typeof useNotebookAPI>;
12+
13+
describe('useWorkspaceFormData', () => {
14+
beforeEach(() => {
15+
jest.clearAllMocks();
16+
});
17+
18+
it('returns empty form data when missing namespace or name', async () => {
19+
mockUseNotebookAPI.mockReturnValue({
20+
api: {} as NotebookApis,
21+
apiAvailable: true,
22+
refreshAllAPI: jest.fn(),
23+
});
24+
const { result, waitForNextUpdate } = renderHook(() =>
25+
useWorkspaceFormData({ namespace: undefined, workspaceName: undefined }),
26+
);
27+
await waitForNextUpdate();
28+
29+
const workspaceFormData = result.current[0];
30+
expect(workspaceFormData).toEqual(EMPTY_FORM_DATA);
31+
});
32+
33+
it('maps workspace and kind into form data when API available', async () => {
34+
const mockWorkspace = buildMockWorkspace({});
35+
const mockWorkspaceKind = buildMockWorkspaceKind({});
36+
const getWorkspace = jest.fn().mockResolvedValue({
37+
ok: true,
38+
data: mockWorkspace,
39+
});
40+
const getWorkspaceKind = jest.fn().mockResolvedValue({ ok: true, data: mockWorkspaceKind });
41+
42+
const api = {
43+
workspaces: { getWorkspace },
44+
workspaceKinds: { getWorkspaceKind },
45+
} as unknown as NotebookApis;
46+
47+
mockUseNotebookAPI.mockReturnValue({
48+
api,
49+
apiAvailable: true,
50+
refreshAllAPI: jest.fn(),
51+
});
52+
53+
const { result, waitForNextUpdate } = renderHook(() =>
54+
useWorkspaceFormData({ namespace: 'ns', workspaceName: 'ws' }),
55+
);
56+
await waitForNextUpdate();
57+
58+
const workspaceFormData = result.current[0];
59+
expect(workspaceFormData).toEqual({
60+
kind: mockWorkspaceKind,
61+
image: {
62+
...mockWorkspace.podTemplate.options.imageConfig.current,
63+
hidden: mockWorkspaceKind.hidden,
64+
},
65+
podConfig: {
66+
...mockWorkspace.podTemplate.options.podConfig.current,
67+
hidden: mockWorkspaceKind.hidden,
68+
},
69+
properties: {
70+
workspaceName: mockWorkspace.name,
71+
deferUpdates: mockWorkspace.deferUpdates,
72+
volumes: mockWorkspace.podTemplate.volumes.data,
73+
secrets: mockWorkspace.podTemplate.volumes.secrets,
74+
homeDirectory: mockWorkspace.podTemplate.volumes.home?.mountPath,
75+
},
76+
});
77+
});
78+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React from 'react';
2+
import { MemoryRouter } from 'react-router-dom';
3+
import { renderHook } from '~/__tests__/unit/testUtils/hooks';
4+
import { useWorkspaceFormLocationData } from '~/app/hooks/useWorkspaceFormLocationData';
5+
import { NamespaceContextProvider } from '~/app/context/NamespaceContextProvider';
6+
7+
jest.mock('~/app/context/NamespaceContextProvider', () => {
8+
const ReactActual = jest.requireActual('react');
9+
const mockNamespaceValue = {
10+
namespaces: ['ns1', 'ns2', 'ns3'],
11+
selectedNamespace: 'ns1',
12+
setSelectedNamespace: jest.fn(),
13+
lastUsedNamespace: 'ns1',
14+
updateLastUsedNamespace: jest.fn(),
15+
};
16+
const MockContext = ReactActual.createContext(mockNamespaceValue);
17+
return {
18+
NamespaceContextProvider: ({ children }: { children: React.ReactNode }) => (
19+
<MockContext.Provider value={mockNamespaceValue}>{children}</MockContext.Provider>
20+
),
21+
useNamespaceContext: () => ReactActual.useContext(MockContext),
22+
};
23+
});
24+
25+
describe('useWorkspaceFormLocationData', () => {
26+
const wrapper: React.FC<
27+
React.PropsWithChildren<{ initialEntries: (string | { pathname: string; state?: unknown })[] }>
28+
> = ({ children, initialEntries }) => (
29+
<MemoryRouter initialEntries={initialEntries}>
30+
<NamespaceContextProvider>{children}</NamespaceContextProvider>
31+
</MemoryRouter>
32+
);
33+
34+
it('returns edit mode data', () => {
35+
const initialEntries = [
36+
{ pathname: '/workspaces/edit', state: { namespace: 'ns2', workspaceName: 'ws' } },
37+
];
38+
const { result } = renderHook(() => useWorkspaceFormLocationData(), {
39+
wrapper: (props) => wrapper({ ...props, initialEntries }),
40+
});
41+
expect(result.current).toEqual({ mode: 'edit', namespace: 'ns2', workspaceName: 'ws' });
42+
});
43+
44+
it('throws when missing workspaceName in edit mode', () => {
45+
const initialEntries = [{ pathname: '/workspaces/edit', state: { namespace: 'ns1' } }];
46+
expect(() =>
47+
renderHook(() => useWorkspaceFormLocationData(), {
48+
wrapper: (props) => wrapper({ ...props, initialEntries }),
49+
}),
50+
).toThrow();
51+
});
52+
53+
it('returns create mode data using selected namespace when state not provided', () => {
54+
const initialEntries = [{ pathname: '/workspaces/create' }];
55+
const { result } = renderHook(() => useWorkspaceFormLocationData(), {
56+
wrapper: (props) => wrapper({ ...props, initialEntries }),
57+
});
58+
expect(result.current).toEqual({ mode: 'create', namespace: 'ns1' });
59+
});
60+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { renderHook } from '~/__tests__/unit/testUtils/hooks';
2+
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
3+
import useWorkspaceKindByName from '~/app/hooks/useWorkspaceKindByName';
4+
import { NotebookApis } from '~/shared/api/notebookApi';
5+
import { buildMockWorkspaceKind } from '~/shared/mock/mockBuilder';
6+
7+
jest.mock('~/app/hooks/useNotebookAPI', () => ({
8+
useNotebookAPI: jest.fn(),
9+
}));
10+
11+
const mockUseNotebookAPI = useNotebookAPI as jest.MockedFunction<typeof useNotebookAPI>;
12+
13+
describe('useWorkspaceKindByName', () => {
14+
beforeEach(() => {
15+
jest.clearAllMocks();
16+
});
17+
18+
it('rejects when API not available', async () => {
19+
mockUseNotebookAPI.mockReturnValue({
20+
api: {} as NotebookApis,
21+
apiAvailable: false,
22+
refreshAllAPI: jest.fn(),
23+
});
24+
25+
const { result, waitForNextUpdate } = renderHook(() => useWorkspaceKindByName('jupyter'));
26+
await waitForNextUpdate();
27+
28+
const [workspaceKind, loaded, error] = result.current;
29+
expect(workspaceKind).toBeNull();
30+
expect(loaded).toBe(false);
31+
expect(error).toBeDefined();
32+
});
33+
34+
it('returns null when no kind provided', async () => {
35+
mockUseNotebookAPI.mockReturnValue({
36+
api: {} as NotebookApis,
37+
apiAvailable: true,
38+
refreshAllAPI: jest.fn(),
39+
});
40+
41+
const { result, waitForNextUpdate } = renderHook(() => useWorkspaceKindByName(undefined));
42+
await waitForNextUpdate();
43+
44+
const [workspaceKind, loaded, error] = result.current;
45+
expect(workspaceKind).toBeNull();
46+
expect(loaded).toBe(true);
47+
expect(error).toBeUndefined();
48+
});
49+
50+
it('returns kind when API is available', async () => {
51+
const mockWorkspaceKind = buildMockWorkspaceKind({});
52+
const getWorkspaceKind = jest.fn().mockResolvedValue({ ok: true, data: mockWorkspaceKind });
53+
mockUseNotebookAPI.mockReturnValue({
54+
api: { workspaceKinds: { getWorkspaceKind } } as unknown as NotebookApis,
55+
apiAvailable: true,
56+
refreshAllAPI: jest.fn(),
57+
});
58+
59+
const { result, waitForNextUpdate } = renderHook(() =>
60+
useWorkspaceKindByName(mockWorkspaceKind.name),
61+
);
62+
await waitForNextUpdate();
63+
64+
const [workspaceKind, loaded, error] = result.current;
65+
expect(workspaceKind).toEqual(mockWorkspaceKind);
66+
expect(loaded).toBe(true);
67+
expect(error).toBeUndefined();
68+
});
69+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { renderHook } from '~/__tests__/unit/testUtils/hooks';
2+
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
3+
import useWorkspaceKinds from '~/app/hooks/useWorkspaceKinds';
4+
import { NotebookApis } from '~/shared/api/notebookApi';
5+
import { buildMockWorkspaceKind } from '~/shared/mock/mockBuilder';
6+
7+
jest.mock('~/app/hooks/useNotebookAPI', () => ({
8+
useNotebookAPI: jest.fn(),
9+
}));
10+
11+
const mockUseNotebookAPI = useNotebookAPI as jest.MockedFunction<typeof useNotebookAPI>;
12+
13+
describe('useWorkspaceKinds', () => {
14+
beforeEach(() => {
15+
jest.clearAllMocks();
16+
});
17+
18+
it('rejects when API not available', async () => {
19+
mockUseNotebookAPI.mockReturnValue({
20+
api: {} as NotebookApis,
21+
apiAvailable: false,
22+
refreshAllAPI: jest.fn(),
23+
});
24+
25+
const { result, waitForNextUpdate } = renderHook(() => useWorkspaceKinds());
26+
await waitForNextUpdate();
27+
28+
const [workspaceKinds, loaded, error] = result.current;
29+
expect(workspaceKinds).toEqual([]);
30+
expect(loaded).toBe(false);
31+
expect(error).toBeDefined();
32+
});
33+
34+
it('returns kinds when API is available', async () => {
35+
const mockWorkspaceKind = buildMockWorkspaceKind({});
36+
const listWorkspaceKinds = jest.fn().mockResolvedValue({ ok: true, data: [mockWorkspaceKind] });
37+
mockUseNotebookAPI.mockReturnValue({
38+
api: { workspaceKinds: { listWorkspaceKinds } } as unknown as NotebookApis,
39+
apiAvailable: true,
40+
refreshAllAPI: jest.fn(),
41+
});
42+
43+
const { result, waitForNextUpdate } = renderHook(() => useWorkspaceKinds());
44+
await waitForNextUpdate();
45+
46+
const [workspaceKinds, loaded, error] = result.current;
47+
expect(workspaceKinds).toEqual([mockWorkspaceKind]);
48+
expect(loaded).toBe(true);
49+
expect(error).toBeUndefined();
50+
});
51+
});

0 commit comments

Comments
 (0)