Skip to content

Commit 438e56b

Browse files
Render the whole app in page-level tests (#565)
* proof of concept for making all page-level tests into integration tests * same treatment for project create * tweaks, eliminate all warnings * automatically update packer-id * tip from good guy KCD cleans things up quite a bit https://github.com/kentcdodds/bookshelf/blob/8cbbd999d/src/test/app-test-utils.js#L15 * no longer need to separate *CreatePage and *CreateForm * minor test tweaks * automatically update packer-id Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent bd3af89 commit 438e56b

File tree

7 files changed

+406
-423
lines changed

7 files changed

+406
-423
lines changed

app/pages/ProjectCreatePage.tsx

Lines changed: 69 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
Success16Icon,
1414
FieldTitle,
1515
} from '@oxide/ui'
16-
import type { Project } from '@oxide/api'
1716
import { useApiMutation, useApiQueryClient } from '@oxide/api'
1817
import { useParams, useToast } from '../hooks'
1918
import { getServerError } from '../util/errors'
@@ -24,105 +23,91 @@ const ERROR_CODES = {
2423
'A project with that name already exists in this organization',
2524
}
2625

27-
// exists primarily so we can test it without worrying about route params
28-
export function ProjectCreateForm({
29-
orgName,
30-
onSuccess,
31-
}: {
32-
orgName: string
33-
onSuccess: (p: Project) => void
34-
}) {
35-
const createProject = useApiMutation('organizationProjectsPost', {
36-
onSuccess,
37-
})
38-
return (
39-
<Formik
40-
initialValues={{ name: '', description: '' }}
41-
onSubmit={({ name, description }) => {
42-
createProject.mutate({
43-
organizationName: orgName,
44-
body: { name, description },
45-
})
46-
}}
47-
>
48-
<Form>
49-
<div className="mb-4">
50-
<FieldTitle htmlFor="project-name">Choose a name</FieldTitle>
51-
<TextField
52-
id="project-name"
53-
name="name"
54-
placeholder="Enter name"
55-
validate={validateName}
56-
autoComplete="off"
57-
/>
58-
<TextFieldError name="name" />
59-
</div>
60-
<div className="mb-8">
61-
<FieldTitle htmlFor="project-description">
62-
Choose a description
63-
</FieldTitle>
64-
<TextFieldHint id="description-hint">
65-
What is unique about your project?
66-
</TextFieldHint>
67-
<TextField
68-
id="project-description"
69-
name="description"
70-
aria-describedby="description-hint"
71-
placeholder="A project"
72-
autoComplete="off"
73-
/>
74-
</div>
75-
<Button
76-
type="submit"
77-
variant="dim"
78-
className="w-[30rem]"
79-
disabled={createProject.isLoading}
80-
>
81-
Create project
82-
</Button>
83-
<div className="text-red-500 mt-2">
84-
{getServerError(createProject.error, ERROR_CODES)}
85-
</div>
86-
</Form>
87-
</Formik>
88-
)
89-
}
90-
9126
export default function ProjectCreatePage() {
9227
const queryClient = useApiQueryClient()
9328
const addToast = useToast()
9429
const navigate = useNavigate()
9530

9631
const { orgName } = useParams('orgName')
32+
33+
const createProject = useApiMutation('organizationProjectsPost', {
34+
onSuccess(project) {
35+
// refetch list of projects in sidebar
36+
queryClient.invalidateQueries('organizationProjectsGet', {
37+
organizationName: orgName,
38+
})
39+
// avoid the project fetch when the project page loads since we have the data
40+
queryClient.setQueryData(
41+
'organizationProjectsGetProject',
42+
{ organizationName: orgName, projectName: project.name },
43+
project
44+
)
45+
addToast({
46+
icon: <Success16Icon />,
47+
title: 'Success!',
48+
content: 'Your project has been created.',
49+
timeout: 5000,
50+
})
51+
navigate(`../${project.name}`)
52+
},
53+
})
54+
9755
return (
9856
<>
9957
<PageHeader>
10058
<PageTitle icon={<Folder24Icon title="Projects" />}>
10159
Create a new project
10260
</PageTitle>
10361
</PageHeader>
104-
<ProjectCreateForm
105-
orgName={orgName}
106-
onSuccess={(project) => {
107-
// refetch list of projects in sidebar
108-
queryClient.invalidateQueries('organizationProjectsGet', {
62+
<Formik
63+
initialValues={{ name: '', description: '' }}
64+
onSubmit={({ name, description }) => {
65+
createProject.mutate({
10966
organizationName: orgName,
67+
body: { name, description },
11068
})
111-
// avoid the project fetch when the project page loads since we have the data
112-
queryClient.setQueryData(
113-
'organizationProjectsGetProject',
114-
{ organizationName: orgName, projectName: project.name },
115-
project
116-
)
117-
addToast({
118-
icon: <Success16Icon />,
119-
title: 'Success!',
120-
content: 'Your project has been created.',
121-
timeout: 5000,
122-
})
123-
navigate(`../${project.name}`)
12469
}}
125-
/>
70+
>
71+
<Form>
72+
<div className="mb-4">
73+
<FieldTitle htmlFor="project-name">Choose a name</FieldTitle>
74+
<TextField
75+
id="project-name"
76+
name="name"
77+
placeholder="Enter name"
78+
validate={validateName}
79+
autoComplete="off"
80+
/>
81+
<TextFieldError name="name" />
82+
</div>
83+
<div className="mb-8">
84+
<FieldTitle htmlFor="project-description">
85+
Choose a description
86+
</FieldTitle>
87+
<TextFieldHint id="description-hint">
88+
What is unique about your project?
89+
</TextFieldHint>
90+
<TextField
91+
id="project-description"
92+
name="description"
93+
aria-describedby="description-hint"
94+
placeholder="A project"
95+
autoComplete="off"
96+
/>
97+
</div>
98+
<Button
99+
type="submit"
100+
variant="dim"
101+
className="w-[30rem]"
102+
disabled={createProject.isLoading}
103+
>
104+
Create project
105+
</Button>
106+
<div className="text-red-500 mt-2">
107+
{getServerError(createProject.error, ERROR_CODES)}
108+
</div>
109+
</Form>
110+
</Formik>
126111
</>
127112
)
128113
}
Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
1-
import React from 'react'
21
import {
32
fireEvent,
4-
lastBody,
5-
renderWithRouter,
3+
lastPostBody,
4+
renderAppAt,
65
screen,
76
waitFor,
87
} from '../../test-utils'
98
import fetchMock from 'fetch-mock'
109

1110
import { org, project, instance } from '@oxide/api-mocks'
1211

13-
import { InstanceCreateForm } from '../project/instances/create/InstancesCreatePage'
14-
1512
const submitButton = () =>
1613
screen.getByRole('button', { name: 'Create instance' })
1714

@@ -20,67 +17,66 @@ const instancesUrl = `${projectUrl}/instances`
2017
const disksUrl = `${projectUrl}/disks`
2118
const vpcsUrl = `${projectUrl}/vpcs`
2219

23-
let successSpy: jest.Mock
24-
25-
describe('InstanceCreateForm', () => {
26-
beforeEach(() => {
27-
// existing disk modal fetches disks on render even if it's not visible
28-
fetchMock.get(disksUrl, 200)
29-
fetchMock.get(vpcsUrl, 200)
30-
successSpy = jest.fn()
31-
renderWithRouter(
32-
<InstanceCreateForm
33-
orgName={org.name}
34-
projectName={project.name}
35-
onSuccess={successSpy}
36-
/>
37-
)
38-
})
20+
const formUrl = `/orgs/${org.name}/projects/${project.name}/instances/new`
3921

22+
const renderPage = () => {
23+
// existing disk modal fetches disks on render even if it's not visible
24+
fetchMock.get(disksUrl, 200)
25+
fetchMock.get(vpcsUrl, 200)
26+
fetchMock.get(projectUrl, 200)
27+
return renderAppAt(formUrl)
28+
}
29+
30+
describe('InstanceCreatePage', () => {
4031
afterEach(() => {
4132
fetchMock.reset()
4233
})
4334

44-
it('disables submit button on submit and enables on response', async () => {
45-
const mock = fetchMock.post(instancesUrl, 201)
35+
it('disables submit button on submit', async () => {
36+
fetchMock.post(instancesUrl, 201)
37+
renderPage()
4638

4739
const submit = submitButton()
4840
expect(submit).not.toBeDisabled()
4941

5042
fireEvent.click(submit)
5143

52-
expect(mock.called(instancesUrl)).toBeFalsy()
5344
await waitFor(() => expect(submit).toBeDisabled())
54-
expect(mock.done()).toBeTruthy()
55-
expect(submit).not.toBeDisabled()
5645
})
5746

5847
it('shows specific message for known server error code', async () => {
5948
fetchMock.post(instancesUrl, {
6049
status: 400,
6150
body: { error_code: 'ObjectAlreadyExists' },
6251
})
52+
renderPage()
6353

6454
fireEvent.click(submitButton())
6555

6656
await screen.findByText(
6757
'An instance with that name already exists in this project'
6858
)
59+
// don't nav away
60+
expect(window.location.pathname).toEqual(formUrl)
6961
})
7062

7163
it('shows generic message for unknown server error', async () => {
7264
fetchMock.post(instancesUrl, {
7365
status: 400,
7466
body: { error_code: 'UnknownCode' },
7567
})
68+
renderPage()
7669

7770
fireEvent.click(submitButton())
7871

7972
await screen.findByText('Unknown error from server')
73+
// don't nav away
74+
expect(window.location.pathname).toEqual(formUrl)
8075
})
8176

8277
it('posts form on submit', async () => {
8378
const mock = fetchMock.post(instancesUrl, 201)
79+
renderPage()
8480

8581
fireEvent.change(screen.getByLabelText('Choose a name'), {
8682
target: { value: 'new-instance' },
@@ -89,7 +85,7 @@ describe('InstanceCreateForm', () => {
8985
fireEvent.click(submitButton())
9086

9187
await waitFor(() =>
92-
expect(lastBody(mock)).toEqual({
88+
expect(lastPostBody(mock)).toEqual({
9389
name: 'new-instance',
9490
description: 'An instance in project: mock-project',
9591
hostname: '',
@@ -99,15 +95,17 @@ describe('InstanceCreateForm', () => {
9995
)
10096
})
10197

102-
it('calls onSuccess on success', async () => {
98+
it('navigates to project instances page on success', async () => {
10399
const mock = fetchMock.post(instancesUrl, { status: 201, body: instance })
100+
renderPage()
104101

105-
expect(successSpy).not.toHaveBeenCalled()
102+
const instancesPage = `/orgs/${org.name}/projects/${project.name}/instances`
103+
expect(window.location.pathname).not.toEqual(instancesPage)
106104

107105
fireEvent.click(submitButton())
108106

109107
await waitFor(() => expect(mock.called(instancesUrl)).toBeTruthy())
110108
await waitFor(() => expect(mock.done()).toBeTruthy())
111-
await waitFor(() => expect(successSpy).toHaveBeenCalled())
109+
await waitFor(() => expect(window.location.pathname).toEqual(instancesPage))
112110
})
113111
})

0 commit comments

Comments
 (0)