Improve Integration Test Setup and re-add LogPreviewer tests (#35358)

* fix logs previewer and msw

* refactor api mocking for better dx

* update readme

* comment out error handler for vitest

* rm unnecessary tests

* fix custom render nuqs type

* add logs search test

* rm unnecessary import

* update readme with customRender and customRednerHook

* rm unnecessary api handler

* Move the NODE_ENV to the studio build command in turbo.json.

* Update apps/studio/tests/README.md

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>

* add cursor rule

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Jordi Enric
2025-05-06 09:50:56 +02:00
committed by GitHub
parent 785fcbacc8
commit 3c588294a6
26 changed files with 343 additions and 257 deletions

View File

@@ -0,0 +1,6 @@
---
description:
globs: apps/studio/**/*.test.ts,apps/studio/**/*.test.tsx
alwaysApply: false
---
Make sure to follow the guidelines in this file to write tests: [README.md](mdc:apps/studio/tests/README.md)

View File

@@ -5,6 +5,7 @@ export * from './infrastructure'
export const IS_PLATFORM = process.env.NEXT_PUBLIC_IS_PLATFORM === 'true'
export const API_URL = (() => {
if (process.env.NODE_ENV === 'test') return 'http://localhost:3000/api'
// If running in platform, use API_URL from the env var
if (IS_PLATFORM) return process.env.NEXT_PUBLIC_API_URL!
// If running in browser, let it add the host

View File

@@ -1,6 +1,75 @@
# UI Testing Notes
### `<Popover>` vs `<Dropdown>`
## Rules
- All tests should be run consistently (avoid situations whereby tests fails "sometimes")
- Group tests in folders based on the feature they are testing. Avoid file/folder based folder names since those can change and we will forget to update the tests.
Examples: /logs /reports /projects /database-settings /auth
## Custom Render and Custom Render Hook
`customRender` and `customRenderHook` are wrappers around `render` and `renderHook` that add some necessary providers like `QueryClientProvider`, `TooltipProvider` and `NuqsTestingAdapter`.
Generally use those instead of the default `render` and `renderHook` functions.
```ts
import { customRender, customRenderHook } from 'tests/lib/custom-render'
customRender(<MyComponent />)
customRenderHook(() => useMyHook())
```
## Mocking API Requests
To mock API requests, we use the `msw` library.
Global mocks can be found in `tests/lib/msw-global-api-mocks.ts`.
To mock an endpoint you can use the `addAPIMock` function. Make sure to add the mock in the `beforeEach` hook. It won't work with `beforeAll` if you have many tests.
```ts
beforeEach(() => {
addAPIMock({
method: 'get',
path: '/api/my-endpoint',
response: {
data: { foo: 'bar' },
},
})
})
```
### API Mocking Tips:
- Keep mocks in the same folder as the tests that use them
- Add a test to verify the mock is working
This will make debugging and updating the mocks easier.
```ts
test('mock is working', async () => {
const response = await fetch('/api/my-endpoint')
expect(response.json()).resolves.toEqual({ data: { foo: 'bar' } })
})
```
## Mocking Nuqs URL Parameters
To render a component that uses Nuqs with some predefined query parameters, you can use `customRender` with the `nuqs` prop.
```ts
customRender(<MyComponent />, {
nuqs: {
searchParams: {
search: 'hello world',
},
},
})
```
## `<Popover>` vs `<Dropdown>`
When simulating clicks on these components, do the following:
@@ -13,7 +82,3 @@ await userEvent.click('Hello world')
import clickDropdown from 'tests/helpers'
clickDropdown('Hello world')
```
### Rules
- All tests should be run consistently (avoid situations whereby tests fails "sometimes")

View File

@@ -9,8 +9,10 @@ test('MSW works as expected', async () => {
expect(json).toEqual({ message: 'Hello from MSW!' })
})
test('MSW fails on missing endpoints', async () => {
test('MSW errors on missing endpoints', async () => {
expect(async () => {
await fetch(`${API_URL}/endpoint-that-doesnt-exist`)
}).rejects.toThrowError()
const res = await fetch(`${API_URL}/endpoint-that-doesnt-exist`)
const json = await res.json()
expect(json).toEqual({ message: '🚫 MSW missed' })
})
})

View File

@@ -1,5 +1,5 @@
import { render, screen, waitFor } from '@testing-library/react'
import { routerMock } from 'tests/mocks/router'
import { routerMock } from '../lib/route-mock'
import { expect, suite, test } from 'vitest'
import { RouterComponent } from './router'

View File

@@ -1,4 +1,4 @@
import { prettyDOM, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LogTable from 'components/interfaces/Settings/Logs/LogTable'
import dayjs from 'dayjs'
@@ -26,36 +26,29 @@ beforeAll(() => {
const fakeMicroTimestamp = dayjs().unix() * 1000
test.skip('can display log data', async () => {
const LOG_DATA = {
id: 'some-uuid',
timestamp: 1621323232312,
event_message: 'event message',
metadata: {
my_key: 'something_value',
},
}
test('can display log data', async () => {
render(
<>
<LogTable
projectRef="projectRef"
data={[
{
id: 'some-uuid',
timestamp: 1621323232312,
event_message: 'event message',
metadata: {
my_key: 'something_value',
},
},
]}
/>
<LogTable projectRef="default" data={[LOG_DATA]} />
</>
)
await screen.findByText(/event message/)
await screen.findAllByText(LOG_DATA.timestamp)
})
prettyDOM(screen.getByText(/event message/))
test('Shows total results', async () => {
render(<LogTable projectRef="default" data={[LOG_DATA]} />)
const row = await screen.findByText(/event message/)
await userEvent.click(row)
// [Joshen] commenting out for now - seems like we need to mock more stuff
await screen.findByText(/my_key/)
await screen.findByText(/something_value/)
await screen.getByText(/results \(1\)/i)
})
test('can run if no queryType provided', async () => {

View File

@@ -0,0 +1,128 @@
import { screen, waitFor } from '@testing-library/react'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { beforeEach, expect, test, vi } from 'vitest'
import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants'
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
import { customRender, customRenderHook } from 'tests/lib/custom-render'
import userEvent from '@testing-library/user-event'
import useLogsPreview from 'hooks/analytics/useLogsPreview'
import { LOGS_API_MOCKS } from './logs.mocks'
import { addAPIMock } from 'tests/lib/msw'
dayjs.extend(utc)
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal()
return {
useParams: vi.fn().mockReturnValue({}),
useIsLoggedIn: vi.fn(),
isBrowser: false,
LOCAL_STORAGE_KEYS: (actual as any).LOCAL_STORAGE_KEYS,
...(actual as any),
}
})
vi.mock('lib/gotrue', async (importOriginal) => ({
...(await importOriginal()),
auth: { onAuthStateChange: vi.fn() },
}))
beforeEach(() => {
addAPIMock({
method: 'get',
path: '/platform/projects/default/analytics/endpoints/logs.all',
response: LOGS_API_MOCKS,
})
})
test('search loads with whatever is on the URL', async () => {
customRender(
<LogsPreviewer queryType="api" projectRef="default" tableName={LogsTableName.EDGE} />,
{
nuqs: {
searchParams: {
s: 'test-search-box-value',
},
},
}
)
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveValue('test-search-box-value')
})
await waitFor(() => {
expect(screen.getByRole('textbox')).not.toHaveValue('WRONGVALUE!🪿')
})
})
test('useLogsPreview returns data from MSW', async () => {
const { result } = customRenderHook(() =>
useLogsPreview({
projectRef: 'default',
table: LogsTableName.EDGE,
})
)
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
await waitFor(() => {
expect(result.current.logData.length).toBeGreaterThan(0)
})
expect(result.current.logData).toEqual(LOGS_API_MOCKS.result)
})
test('LogsPreviewer renders the expected data from the API', async () => {
customRender(
<LogsPreviewer queryType="api" projectRef="default" tableName={LogsTableName.EDGE} />
)
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument()
})
const firstLogEventMessage = LOGS_API_MOCKS.result[0].event_message
await waitFor(() => {
expect(screen.getAllByText(firstLogEventMessage)[0]).toBeInTheDocument()
})
})
test('can toggle log event chart', async () => {
customRender(
<LogsPreviewer queryType="api" projectRef="default" tableName={LogsTableName.EDGE} />
)
expect(screen.getByRole('button', { name: /Chart/i })).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByTestId('logs-bar-chart')).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('button', { name: /Chart/i }))
await waitFor(() => {
expect(screen.queryByTestId('logs-bar-chart')).not.toBeInTheDocument()
})
})
test('can click load older', async () => {
customRender(
<LogsPreviewer queryType="api" projectRef="default" tableName={LogsTableName.EDGE} />
)
const loadOlder = await waitFor(
async () => await screen.findByRole('button', { name: /Load older/i })
)
loadOlder.onclick = vi.fn()
await userEvent.click(loadOlder)
expect(loadOlder.onclick).toHaveBeenCalled()
})

View File

@@ -4,7 +4,7 @@ import dayjs from 'dayjs'
import { LogsExplorerPage } from 'pages/project/[ref]/logs/explorer/index'
import { clickDropdown } from 'tests/helpers'
import { customRender as render } from 'tests/lib/custom-render'
import { routerMock } from 'tests/mocks/router'
import { routerMock } from 'tests/lib/route-mock'
import { beforeAll, describe, expect, test, vi } from 'vitest'
const router = routerMock

View File

@@ -0,0 +1,22 @@
export const LOGS_API_MOCKS = {
result: [
{
id: 'uuid',
event_message: 'foobar',
timestamp: 1298085933,
error_count: 1,
warning_count: 2,
ok_count: 3,
count: 2,
},
{
id: 'uuid2',
event_message: 'foobar',
timestamp: 1298085933,
error_count: 1,
warning_count: 2,
ok_count: 3,
count: 2,
},
],
}

View File

@@ -2,7 +2,7 @@ import { screen } from '@testing-library/react'
import { beforeAll, test, vi } from 'vitest'
import { ApiReport } from 'pages/project/[ref]/reports/api-overview'
import { render } from '../../../helpers'
import { render } from 'tests/helpers'
// [Joshen] Mock data for ApiReport is in __mocks__/hooks/useApiReport
// I don't think this is an ideal set up as the mock data is not clear in this file itself

View File

@@ -2,7 +2,7 @@ import { screen } from '@testing-library/react'
import { test } from 'vitest'
import { StorageReport } from 'pages/project/[ref]/reports/storage'
import { render } from '../../../helpers'
import { render } from 'tests/helpers'
// [Joshen] Mock data for ApiReport is in __mocks__/hooks/useStorageReport
// I don't think this is an ideal set up as the mock data is not clear in this file itself

View File

@@ -1,9 +1,9 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, RenderOptions } from '@testing-library/react'
import { render, renderHook, RenderOptions } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { TooltipProvider } from 'ui'
type AdapterProps = Parameters<typeof NuqsTestingAdapter>[0]
type AdapterProps = Partial<Parameters<typeof NuqsTestingAdapter>[0]>
const CustomWrapper = ({
children,
@@ -14,7 +14,15 @@ const CustomWrapper = ({
queryClient?: QueryClient
nuqs?: AdapterProps
}) => {
const _queryClient = queryClient ?? new QueryClient()
const _queryClient =
queryClient ??
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return (
<QueryClientProvider client={_queryClient}>
@@ -41,3 +49,15 @@ export const customRender = (component: React.ReactElement, renderOptions?: Cust
...renderOptions,
})
}
export const customRenderHook = (hook: () => any, renderOptions?: CustomRenderOpts) => {
return renderHook(hook, {
wrapper: ({ children }) =>
CustomWrapper({
children,
queryClient: renderOptions?.queryClient,
nuqs: renderOptions?.nuqs,
}),
...renderOptions,
})
}

View File

@@ -0,0 +1,26 @@
import { API_URL } from 'lib/constants'
import { HttpResponse, http } from 'msw'
export const GlobalAPIMocks = [
http.get(`${API_URL}/msw/test`, () => {
return HttpResponse.json({ message: 'Hello from MSW!' })
}),
http.get(`${API_URL}/platform/projects/default/databases`, () => {
return HttpResponse.json([
{
cloud_provider: 'AWS',
connectionString: '123',
connection_string_read_only: '123',
db_host: '123',
db_name: 'postgres',
db_port: 5432,
identifier: 'default',
inserted_at: '2025-02-16T22:24:42.115195',
region: 'us-east-1',
restUrl: 'https://default.supabase.co',
size: 't4g.nano',
status: 'ACTIVE_HEALTHY',
},
])
}),
]

View File

@@ -0,0 +1,25 @@
import { setupServer } from 'msw/node'
import { GlobalAPIMocks } from './msw-global-api-mocks'
import { http, HttpResponse } from 'msw'
import { API_URL } from 'lib/constants'
export const mswServer = setupServer(...GlobalAPIMocks)
export const addAPIMock = ({
method,
path,
response,
}: {
method: keyof typeof http
path: string
response: any
}) => {
const fullPath = `${API_URL}${path}`
console.log('[MSW] Adding mock:', method, fullPath)
mswServer.use(
http[method](fullPath, () => {
return HttpResponse.json(response)
})
)
}

View File

@@ -1,84 +0,0 @@
import { API_URL } from 'lib/constants'
import { HttpResponse, http } from 'msw'
const PROJECT_REF = 'default'
export const APIMock = [
http.get(`${API_URL}/msw/test`, () => {
return HttpResponse.json({ message: 'Hello from MSW!' })
}),
http.get(`${API_URL}/platform/projects/${PROJECT_REF}/analytics/endpoints/logs.all`, () => {
return HttpResponse.json({
totalRequests: [
{
count: 12,
timestamp: '2024-05-13T20:00:00.000Z',
},
],
errorCounts: [],
responseSpeed: [
{
avg: 1017.0000000000001,
timestamp: '2024-05-13T20:00:00',
},
],
topRoutes: [
{
count: 6,
method: 'GET',
path: '/auth/v1/user',
search: null,
status_code: 200,
},
{
count: 4,
method: 'GET',
path: '/rest/v1/',
search: null,
status_code: 200,
},
{
count: 2,
method: 'HEAD',
path: '/rest/v1/',
search: null,
status_code: 200,
},
],
topErrorRoutes: [],
topSlowRoutes: [
{
avg: 1093.5,
count: 4,
method: 'GET',
path: '/rest/v1/',
search: null,
status_code: 200,
},
{
avg: 1037.5,
count: 2,
method: 'HEAD',
path: '/rest/v1/',
search: null,
status_code: 200,
},
{
avg: 959.1666666666667,
count: 6,
method: 'GET',
path: '/auth/v1/health',
search: null,
status_code: 200,
},
],
networkTraffic: [
{
egress_mb: 0.000666,
ingress_mb: 0,
timestamp: '2024-05-13T20:00:00.000Z',
},
],
})
}),
]

View File

@@ -1,78 +0,0 @@
import { findByText, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { useRouter } from 'next/router'
import { expect, test, vi } from 'vitest'
dayjs.extend(utc)
import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants'
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
import { render } from '../../helpers'
// [Joshen] There's gotta be a much better way to mock these things so that it applies for ALL tests
// Since these are easily commonly used things across all pages/components that we might be testing for
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal()
return {
useParams: vi.fn().mockReturnValue({}),
useIsLoggedIn: vi.fn(),
isBrowser: false,
LOCAL_STORAGE_KEYS: (actual as any).LOCAL_STORAGE_KEYS,
}
})
vi.mock('lib/gotrue', () => ({
auth: { onAuthStateChange: vi.fn() },
}))
test.skip('Search will trigger a log refresh', async () => {
render(<LogsPreviewer projectRef="123" queryType="auth" />)
await userEvent.type(screen.getByPlaceholderText(/Search events/), 'something{enter}')
await waitFor(
() => {
// updates router query params
const router = useRouter()
expect(router.push).toHaveBeenCalledWith(
expect.objectContaining({
pathname: expect.any(String),
query: expect.objectContaining({
s: expect.stringContaining('something'),
}),
})
)
},
{ timeout: 1500 }
)
const table = await screen.findByRole('table')
await findByText(table, /some-message/, { selector: '*' }, { timeout: 1500 })
})
test.skip('poll count for new messages', async () => {
render(<LogsPreviewer queryType="api" projectRef="123" tableName={LogsTableName.EDGE} />)
await waitFor(() => screen.queryByText(/200/) === null)
// should display new logs count
await waitFor(() => screen.getByText(/999/))
// Refresh button only exists with the queryType param, which no longer shows the id column
await userEvent.click(screen.getByTitle('refresh'))
await waitFor(() => screen.queryByText(/999/) === null)
await screen.findByText(/200/)
})
test.skip('stop polling for new count on error', async () => {
render(<LogsPreviewer queryType="api" projectRef="123" tableName={LogsTableName.EDGE} />)
await waitFor(() => screen.queryByText(/some-uuid123/) === null)
// should display error
await screen.findByText(/some logflare error/)
// should not load refresh counts if no data from main query
await expect(screen.findByText(/999/)).rejects.toThrowError()
})
test.skip('log event chart', async () => {
render(<LogsPreviewer queryType="api" projectRef="123" tableName={LogsTableName.EDGE} />)
await waitFor(() => screen.queryByText(/some-uuid123/) === null)
})

View File

@@ -1,37 +0,0 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
test.skip('filter input change and submit', async () => {
const mockFn = vi.fn()
// render(<PreviewFilterPanel onSearch={mockFn} queryUrl={'/'} />)
expect(mockFn).not.toBeCalled()
const search = screen.getByPlaceholderText(/Search/)
await userEvent.type(search, '12345{enter}')
expect(mockFn).toBeCalled()
})
// test('filter input value', async () => {
// render(<PreviewFilterPanel defaultSearchValue={'1234'} queryUrl={'/'} />)
// await screen.findByDisplayValue('1234')
// })
// test('Manual refresh', async () => {
// const mockFn = vi.fn()
// render(<PreviewFilterPanel onRefresh={mockFn} queryUrl={'/'} />)
// const btn = await screen.findByTitle('refresh')
// await userEvent.click(btn)
// expect(mockFn).toBeCalled()
// })
// test('Datepicker dropdown', async () => {
// const fn = vi.fn()
// render(<PreviewFilterPanel onSearch={fn} queryUrl={'/'} />)
// clickDropdown(await screen.findByText(/Last hour/))
// await userEvent.click(await screen.findByText(/Last 3 hours/))
// expect(fn).toBeCalled()
// })
// test('shortened count to K', async () => {
// render(<PreviewFilterPanel newCount={1234} queryUrl={'/'} />)
// await screen.findByText(/1\.2K/)
// })

View File

@@ -1,8 +0,0 @@
// mock the fetch function
import { test } from 'vitest'
test('on load, refresh user content', async () => {
// get.mockResolvedValue({})
// render(<LogsSavedPage />)
// expect(get).toBeCalled()
})

View File

@@ -1,14 +1,11 @@
/// <reference types="@testing-library/jest-dom" />
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { setupServer } from 'msw/node'
import { cleanup, configure } from '@testing-library/react'
import { createDynamicRouteParser } from 'next-router-mock/dist/dynamic-routes'
import { afterAll, afterEach, beforeAll, vi } from 'vitest'
import { APIMock } from './mocks/api'
import { routerMock } from './mocks/router'
import { routerMock } from './lib/route-mock'
import { mswServer } from './lib/msw'
export const mswServer = setupServer(...APIMock)
mswServer.listen({ onUnhandledRequest: 'error' })
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -24,10 +21,17 @@ Object.defineProperty(window, 'matchMedia', {
})),
})
beforeAll(() => {
console.log('🤖 Starting MSW Server')
// Uncomment this if HTML in errors are being annoying.
//
// configure({
// getElementError: (message, container) => {
// const error = new Error(message ?? 'Element not found')
// error.name = 'ElementNotFoundError'
// return error
// },
// })
mswServer.listen({ onUnhandledRequest: 'error' })
beforeAll(() => {
vi.mock('next/router', () => require('next-router-mock'))
vi.mock('next/navigation', async () => {
const actual = await vi.importActual('next/navigation')

View File

@@ -43,7 +43,7 @@ export const LogsBarChart = ({
const endDate = dayjs(data[data?.length - 1]?.['timestamp']).format(DateTimeFormat)
return (
<div className={cn('flex flex-col gap-y-3')}>
<div data-testid="logs-bar-chart" className={cn('flex flex-col gap-y-3')}>
<ChartContainer
config={
{

View File

@@ -41,6 +41,7 @@
"NEXT_PUBLIC_NODE_ENV",
"NEXT_PUBLIC_GOTRUE_URL",
"NEXT_PUBLIC_VERCEL_BRANCH_URL",
"NODE_ENV",
"SUPABASE_URL",
// These envs are used in the packages
"NEXT_PUBLIC_STORAGE_KEY",