-
Notifications
You must be signed in to change notification settings - Fork 21
Replace fetch-mock with Mock Service Workers #571
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
Changes from all commits
db711b6
6885090
fc39705
0668de7
ee6ba83
a30e2bb
d6fa036
80fc66f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,37 +1,17 @@ | ||
| import { | ||
| fireEvent, | ||
| lastPostBody, | ||
| renderAppAt, | ||
| screen, | ||
| waitFor, | ||
| } from 'app/test-utils' | ||
| import fetchMock from 'fetch-mock' | ||
|
|
||
| import { org, project, instance, sessionMe } from '@oxide/api-mocks' | ||
| import { fireEvent, renderAppAt, screen, waitFor } from 'app/test-utils' | ||
| import { msw, org, project } from '@oxide/api-mocks' | ||
|
|
||
| const submitButton = () => | ||
| screen.getByRole('button', { name: 'Create instance' }) | ||
|
|
||
| const projectUrl = `/api/organizations/${org.name}/projects/${project.name}` | ||
| const instancesUrl = `${projectUrl}/instances` | ||
| const disksUrl = `${projectUrl}/disks` | ||
| const vpcsUrl = `${projectUrl}/vpcs` | ||
|
|
||
| const formUrl = `/orgs/${org.name}/projects/${project.name}/instances/new` | ||
|
|
||
| const renderPage = () => { | ||
| // existing disk modal fetches disks on render even if it's not visible | ||
| fetchMock.get('/api/session/me', { status: 200, body: sessionMe }) | ||
| fetchMock.get(disksUrl, 200) | ||
| fetchMock.get(vpcsUrl, 200) | ||
| fetchMock.get(projectUrl, 200) | ||
| return renderAppAt(formUrl) | ||
| } | ||
|
|
||
| describe('InstanceCreatePage', () => { | ||
| it('disables submit button on submit', async () => { | ||
| fetchMock.post(instancesUrl, 201) | ||
| renderPage() | ||
| renderAppAt(formUrl) | ||
|
|
||
| const submit = submitButton() | ||
| expect(submit).not.toBeDisabled() | ||
|
|
@@ -42,11 +22,10 @@ describe('InstanceCreatePage', () => { | |
| }) | ||
|
|
||
| it('shows specific message for known server error code', async () => { | ||
| fetchMock.post(instancesUrl, { | ||
| status: 400, | ||
| body: { error_code: 'ObjectAlreadyExists' }, | ||
| msw.override('post', instancesUrl, 400, { | ||
| error_code: 'ObjectAlreadyExists', | ||
| }) | ||
| renderPage() | ||
| renderAppAt(formUrl) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is clean enough, but we could also hard code a special instance name like |
||
|
|
||
| fireEvent.click(submitButton()) | ||
|
|
||
|
|
@@ -58,11 +37,8 @@ describe('InstanceCreatePage', () => { | |
| }) | ||
|
|
||
| it('shows generic message for unknown server error', async () => { | ||
| fetchMock.post(instancesUrl, { | ||
| status: 400, | ||
| body: { error_code: 'UnknownCode' }, | ||
| }) | ||
| renderPage() | ||
| msw.override('post', instancesUrl, 400, { error_code: 'UnknownCode' }) | ||
| renderAppAt(formUrl) | ||
|
|
||
| fireEvent.click(submitButton()) | ||
|
|
||
|
|
@@ -71,38 +47,22 @@ describe('InstanceCreatePage', () => { | |
| expect(window.location.pathname).toEqual(formUrl) | ||
| }) | ||
|
|
||
| it('posts form on submit', async () => { | ||
| const mock = fetchMock.post(instancesUrl, 201) | ||
| renderPage() | ||
|
|
||
| fireEvent.change(screen.getByLabelText('Choose a name'), { | ||
| target: { value: 'new-instance' }, | ||
| }) | ||
| fireEvent.click(screen.getByLabelText(/6 CPUs/)) | ||
| fireEvent.click(submitButton()) | ||
|
|
||
| await waitFor(() => | ||
| expect(lastPostBody(mock)).toEqual({ | ||
| name: 'new-instance', | ||
| description: 'An instance in project: mock-project', | ||
| hostname: '', | ||
| ncpus: 6, | ||
| memory: 25769803776, | ||
| }) | ||
| ) | ||
| }) | ||
|
|
||
| it('navigates to project instances page on success', async () => { | ||
| const mock = fetchMock.post(instancesUrl, { status: 201, body: instance }) | ||
| renderPage() | ||
| renderAppAt(formUrl) | ||
|
|
||
| const instancesPage = `/orgs/${org.name}/projects/${project.name}/instances` | ||
| expect(window.location.pathname).not.toEqual(instancesPage) | ||
|
|
||
| // TODO: once MSW data layer is in place, uncomment these and assert that | ||
| // the name shows up in the list of instances | ||
|
|
||
| // fireEvent.change(screen.getByLabelText('Choose a name'), { | ||
| // target: { value: 'new-instance' }, | ||
| // }) | ||
| // fireEvent.click(screen.getByLabelText(/6 CPUs/)) | ||
|
|
||
| fireEvent.click(submitButton()) | ||
|
|
||
| await waitFor(() => expect(mock.called(instancesUrl)).toBeTruthy()) | ||
| await waitFor(() => expect(mock.done()).toBeTruthy()) | ||
| await waitFor(() => expect(window.location.pathname).toEqual(instancesPage)) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,17 +1,7 @@ | ||
| import { | ||
| fireEvent, | ||
| lastPostBody, | ||
| renderAppAt, | ||
| screen, | ||
| waitFor, | ||
| } from 'app/test-utils' | ||
| import fetchMock from 'fetch-mock' | ||
|
|
||
| import { org, project, projects, sessionMe } from '@oxide/api-mocks' | ||
| import { fireEvent, renderAppAt, screen, waitFor } from 'app/test-utils' | ||
| import { msw, org, project } from '@oxide/api-mocks' | ||
|
|
||
| const projectsUrl = `/api/organizations/${org.name}/projects` | ||
| const projectUrl = `${projectsUrl}/${project.name}` | ||
| const instancesUrl = `${projectUrl}/instances?limit=10` | ||
|
|
||
| const submitButton = () => | ||
| screen.getByRole('button', { name: 'Create project' }) | ||
|
|
@@ -24,21 +14,13 @@ function enterName(value: string) { | |
| const formUrl = `/orgs/${org.name}/projects/new` | ||
|
|
||
| const renderPage = () => { | ||
| // fetch projects list for org layout sidebar on project create | ||
| fetchMock.get('/api/session/me', { status: 200, body: sessionMe }) | ||
| fetchMock.get(projectsUrl, { status: 200, body: projects }) | ||
| const result = renderAppAt(formUrl) | ||
| enterName('valid-name') | ||
| return result | ||
| } | ||
|
|
||
| describe('ProjectCreatePage', () => { | ||
| afterEach(() => { | ||
| fetchMock.reset() | ||
| }) | ||
|
|
||
| it('disables submit button on submit', async () => { | ||
| fetchMock.post(projectsUrl, { status: 201 }) | ||
| renderPage() | ||
|
|
||
| const submit = submitButton() | ||
|
|
@@ -50,9 +32,8 @@ describe('ProjectCreatePage', () => { | |
| }) | ||
|
|
||
| it('shows message for known error code in project create code map', async () => { | ||
| fetchMock.post(projectsUrl, { | ||
| status: 400, | ||
| body: { error_code: 'ObjectAlreadyExists' }, | ||
| msw.override('post', projectsUrl, 400, { | ||
| error_code: 'ObjectAlreadyExists', | ||
| }) | ||
| renderPage() | ||
|
|
||
|
|
@@ -66,10 +47,7 @@ describe('ProjectCreatePage', () => { | |
| }) | ||
|
|
||
| it('shows message for known error code in global code map', async () => { | ||
| fetchMock.post(projectsUrl, { | ||
| status: 403, | ||
| body: { error_code: 'Forbidden' }, | ||
| }) | ||
| msw.override('post', projectsUrl, 403, { error_code: 'Forbidden' }) | ||
| renderPage() | ||
|
|
||
| fireEvent.click(submitButton()) | ||
|
|
@@ -90,10 +68,7 @@ describe('ProjectCreatePage', () => { | |
| }) | ||
|
|
||
| it('shows generic message for unknown server error', async () => { | ||
| fetchMock.post(projectsUrl, { | ||
| status: 400, | ||
| body: { error_code: 'UnknownCode' }, | ||
| }) | ||
| msw.override('post', projectsUrl, 400, { error_code: 'UnknownCode' }) | ||
| renderPage() | ||
|
|
||
| fireEvent.click(submitButton()) | ||
|
|
@@ -103,35 +78,15 @@ describe('ProjectCreatePage', () => { | |
| expect(window.location.pathname).toEqual(formUrl) | ||
| }) | ||
|
|
||
| it('posts form on submit', async () => { | ||
| const mock = fetchMock.post(projectsUrl, { status: 201 }) | ||
| renderPage() | ||
|
|
||
| fireEvent.click(submitButton()) | ||
|
|
||
| await waitFor(() => | ||
| expect(lastPostBody(mock)).toEqual({ | ||
| name: 'valid-name', | ||
| description: '', | ||
| }) | ||
| ) | ||
| }) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I kind of miss these tests, but I get that they're an implementation detail. The more correct MSW way of doing it is to have MSW actually add the posted thing to a mock database and then assert that it shows up in the UI.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, this is where that data library would come in handy. |
||
|
|
||
| it('navigates to project instances page on success', async () => { | ||
| fetchMock.post(projectsUrl, { | ||
| status: 201, | ||
| body: project, | ||
| }) | ||
| fetchMock.get(projectUrl, { status: 200 }) | ||
| // instances fetch after success | ||
| fetchMock.get(instancesUrl, { status: 200, body: { items: [] } }) | ||
|
|
||
| renderPage() | ||
| const projectPath = `/orgs/${org.name}/projects/${project.name}/instances` | ||
| expect(window.location.pathname).not.toEqual(projectPath) | ||
|
|
||
| fireEvent.click(submitButton()) | ||
|
|
||
| await waitFor(() => expect(window.location.pathname).toEqual(projectPath)) | ||
|
|
||
| // TODO: navigate to projects page so you can see the project in the list? | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,36 +1,17 @@ | ||
| import { | ||
| fireEvent, | ||
| lastPostBody, | ||
| renderAppAt, | ||
| screen, | ||
| userEvent, | ||
| waitForElementToBeRemoved, | ||
| } from 'app/test-utils' | ||
| import fetchMock from 'fetch-mock' | ||
| import { | ||
| org, | ||
| project, | ||
| vpc, | ||
| vpcSubnet, | ||
| vpcSubnet2, | ||
| vpcSubnets, | ||
| } from '@oxide/api-mocks' | ||
| import { msw, org, project, vpcSubnet, vpcSubnet2 } from '@oxide/api-mocks' | ||
|
|
||
| const vpcUrl = `/api/organizations/${org.name}/projects/${project.name}/vpcs/default` | ||
| const subnetsUrl = `${vpcUrl}/subnets` | ||
| const getSubnetsUrl = `${subnetsUrl}?limit=10` | ||
| const subnetsUrl = `/api/organizations/${org.name}/projects/${project.name}/vpcs/default/subnets` | ||
|
|
||
| describe('VpcPage', () => { | ||
| describe('subnets tab', () => { | ||
| it('creating a subnet works', async () => { | ||
| fetchMock.get('/api/session/me', 200) | ||
| fetchMock.get(vpcUrl, { status: 200, body: vpc }) | ||
| fetchMock.getOnce(getSubnetsUrl, { status: 200, body: vpcSubnets }) | ||
| const postMock = fetchMock.postOnce(subnetsUrl, { | ||
| status: 201, | ||
| body: vpcSubnet2, | ||
| }) | ||
|
|
||
| renderAppAt('/orgs/mock-org/projects/mock-project/vpcs/default') | ||
| screen.getByText('Subnets') | ||
|
|
||
|
|
@@ -52,35 +33,30 @@ describe('VpcPage', () => { | |
| const name = screen.getByRole('textbox', { name: 'Name' }) | ||
| userEvent.type(name, 'mock-subnet-2') | ||
|
|
||
| // override the subnets GET to include both subnets | ||
| fetchMock.getOnce( | ||
| getSubnetsUrl, | ||
| { | ||
| status: 200, | ||
| body: { items: [vpcSubnet, vpcSubnet2] }, | ||
| }, | ||
| { overwriteRoutes: true } | ||
| ) | ||
| // this is temporary, a workaround for the fact that the mock server | ||
| // doesn't have a persistence layer yet | ||
| msw.override('get', subnetsUrl, 200, { items: [vpcSubnet, vpcSubnet2] }) | ||
|
|
||
| // submit the form | ||
| fireEvent.click(screen.getByRole('button', { name: 'Create subnet' })) | ||
|
|
||
| // wait for modal to close | ||
| await waitForElementToBeRemoved(() => | ||
| screen.queryByRole('dialog', { name: 'Create subnet' }) | ||
| await waitForElementToBeRemoved( | ||
| () => screen.queryByRole('dialog', { name: 'Create subnet' }), | ||
| // fails in CI without a longer timeout (default 1000). boo | ||
| { timeout: 2000 } | ||
| ) | ||
|
|
||
| // it posted the form | ||
| expect(lastPostBody(postMock)).toEqual({ | ||
| ipv4Block: '1.1.1.2/24', | ||
| ipv6Block: null, | ||
| name: 'mock-subnet-2', | ||
| description: '', | ||
| }) | ||
| // TODO: before, we asserted what body the form posted. MSW strongly | ||
| // discourages this because it's testing implementation details, but I | ||
| // can't shake the feeling that I want it. But it might feel better after | ||
| // the MSW mock is more sophisticated and actually handles a create by | ||
| // inserting the thing in the list of subnets. Then our assertion that it | ||
| // showed up in the list actually does check what was posted. | ||
|
Comment on lines
+50
to
+55
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree that testing the body of the post is testing implementation details. Having the data layer plugged in means we could test the results of the post which feels like a more valuable test to me. The important part here is that we don't actually want the test to have internal details about the shape of the form. If we assert against the shape of the body then we're tightly coupling the test to the forms internal representation which would be best avoided.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That’s a good point, and it’s even stronger when you consider more complex inputs than text fields. With a text field, the internal representation of the value is just the string you typed (usually). But with a multi-select the internal representation is more opaque. |
||
|
|
||
| // table should refetch and now include second subnet | ||
| screen.getByRole('cell', { name: vpcSubnet.identity.name }) | ||
| screen.getByRole('cell', { name: vpcSubnet2.identity.name }) | ||
| await screen.findByRole('cell', { name: vpcSubnet2.identity.name }) | ||
| }) | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
moving this before build permanently because the two builds take 30 seconds and basically never fail. might as well get test failures sooner