Skip to content
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

Add component tests #163

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions __mock__/@zooniverse/panoptes-js.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const subjects = {
get: (subjectId) => {
return Promise.resolve(null)
}
}

module.exports = {
subjects
}
11 changes: 11 additions & 0 deletions __mock__/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Jest Mocks

This folder contains modules mocked for Jest tests.

If one of our components has `import { banana } from 'fruitlist'`, and that fruitlist module is complex, server-reliant, or otherwise borking our tests, then expect to find a `fruitlist.js` file in here.

Additional notes (accurate as of Jest 29.6.4):

- The `__mock__` folder is _implicit_ to Jest: https://jestjs.io/docs/manual-mocks
- The `__mock__` folder can handle scoped modules, e.g. `@zooniverse/panoptes-js`, by using subfolders.
- However, if the `import` command is pulling from _subfolders,_ then the `__mock__` folder doesn't seem to function as well. See `App.spec.js` for how it handles `import auth from 'panoptes-client/lib/auth'` using `jest.mock('panoptes-client/lib/auth', ...)`
26 changes: 26 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const config = {
'moduleNameMapper': {
// Maps all media files to a mock file.
// Without this, test will crash with a "Jest encountered an unexpected token" due to Jest having no idea what to do with binary files.
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/jest/mockMediaFile.js',

// Maps the @src alias added in webpack.
'^@src/(.*)$': '<rootDir>/src/$1'
},

// Tells Jest to look for unit test files in the /src directory.
'roots': [
'<rootDir>/src'
],

'setupFilesAfterEnv': [
'<rootDir>/jest/jest-setup.js'
],

// Without this, render() will result in "document is not defined" errors.
'testEnvironment': 'jsdom',
}

// Jest doesn't like 'export' (i.e. ES6 modules), so we use module.exports (CommonJS modules).
// Probably because it's running in Node.js.
Copy link
Contributor

@eatyourgreens eatyourgreens Aug 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node supports export but only for ESM, ie. type: 'module' in package.json or .mjs files.

https://nodejs.org/api/esm.html#enabling

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repo's package.json doesn't declare the package type, so Node and Jest default to CommonJS.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Jim, that's very helpful to know! I didn't realise Node.js could support ESM.

I took some time to explore converting this package to an ESM, but that's gonna be a work in progress.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pure ESM might be a good idea when starting a new project, but it's definitely a fair bit of work converting an existing package.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, #153 is failing the tests because @zooniverse/react-components now depends on d3, for SVG data subjects, and d3 is pure ESM.

module.exports = config
11 changes: 0 additions & 11 deletions jest.config.json

This file was deleted.

File renamed without changes.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"build": "export HEAD_COMMIT=${HEAD_COMMIT:-$(git rev-parse --short HEAD)} ; webpack --config webpack.prod.js",
"start": "webpack serve --config webpack.dev.js",
"test": "jest"
"test": "jest --config jest.config.js"
},
"repository": {
"type": "git",
Expand Down
36 changes: 10 additions & 26 deletions src/App/App.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@ import { MemoryRouter } from 'react-router-dom'
import App from './App.js'

// mock Panoptes auth calls
import auth from 'panoptes-client/lib/auth'
jest.mock('panoptes-client/lib/auth')
auth.checkCurrent.mockResolvedValue(null)
jest.mock('panoptes-client/lib/auth', () => {
return {
__esModule: true,
shaunanoordin marked this conversation as resolved.
Show resolved Hide resolved
default: {
shaunanoordin marked this conversation as resolved.
Show resolved Hide resolved
checkCurrent: () => {
return Promise.resolve(null)
}
}
}
})

describe('App', function () {
test('should render logo', async function () {
Expand All @@ -17,27 +24,4 @@ describe('App', function () {
const logo = await screen.findByText(/Zooniverse Logo/i)
expect(logo).toBeInTheDocument()
})

/*
describe('when user isn\'t logged in', function () {
test('should display "isn\'t logged in" message', async function () {
auth.checkCurrent.mockResolvedValue(null)
render(<App />)
const userText = await screen.findByText(/User isn't logged in/i)
expect(userText).toBeInTheDocument()
})
})

describe('when user is logged in', function () {
test('should display user details', async function () {
auth.checkCurrent.mockResolvedValue({
display_name: 'Test User',
login: 'testuser',
})
render(<App />)
const userText = await screen.findByText(/Logged in as Test User/i)
expect(userText).toBeInTheDocument()
})
})
*/
})
1 change: 0 additions & 1 deletion src/components/ProjectContainer/ProjectContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import projectsJson from '@src/projects.json'

export default function ProjectContainer ({}) {
const store = useStores()

const params = useParams()
const projectOwner = params.projectOwner || ''
const projectName = params.projectName || ''
Expand Down
42 changes: 42 additions & 0 deletions src/components/ProjectContainer/ProjectContainer.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import ProjectContainer from './ProjectContainer.js'

import { AppContext } from '@src/store'

describe('ProjectContainers', function () {
let selectedProject
const store = {
project: {
name: 'Placeholder Project',
slug: 'foo/bar',
id: '1234'
},
user: null,
showingSensitiveContent: false,
setProject: (p) => { selectedProject = p },
setUser: () => {},
setShowingSensitiveContent: () => {},
}

test('should find (and set) a project based on the route/path', async function () {
selectedProject = null

render(
<AppContext.Provider value={store}>
<MemoryRouter initialEntries={[
'/projects/darkeshard/community-catalog' /* Input: path to the intended project */
]}>
<Routes>
<Route
path='/projects/:projectOwner/:projectName'
element={<ProjectContainer />} /* Output: we expect ProjectContainer to call setProject() with the intended project */
/>
</Routes>
</MemoryRouter>
</AppContext.Provider>
)

expect(selectedProject?.name).toEqual('Community Catalog (Stable Test Project)')
})
})
84 changes: 84 additions & 0 deletions src/components/SubjectImage/SubjectImage.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { act, render, screen } from '@testing-library/react'
import SubjectImage from './SubjectImage.js'

// Example data
const exampleSubject_87892456 = {
id: '87892456',
locations: [
{ 'image/jpeg': 'https://panoptes-uploads.zooniverse.org/subject_location/66fd834d-b13a-40f0-b97d-6d364841d56c.jpeg' },
{ 'image/jpeg': 'https://panoptes-uploads.zooniverse.org/subject_location/39c77068-22eb-472d-b280-1af9e2f33437.jpeg' }
]
}

// mock Panoptes module
jest.mock('@zooniverse/panoptes-js', () => {
return {
__esModule: true,
shaunanoordin marked this conversation as resolved.
Show resolved Hide resolved
subjects: {
get: ({ id }) => {
if (id === '87892456') {
const mockResponse = {
body: {
subjects: [ exampleSubject_87892456 ]
},
ok: true,
status: 200,
statusCode: 200,
}

return Promise.resolve(mockResponse)
}
return Promise.resolve(null)
}
}
}
})

describe.only('SubjectImage', function () {
test('should render an image, when given a subject', function () {
render(
<SubjectImage
subject={exampleSubject_87892456}
/>
)

// For multi-image subjects, SubjectImage will render the FIRST image.
expect(screen.getByRole('img')).toHaveProperty('alt', 'Preview image for Subject 87892456')
expect(screen.getByRole('img')).toHaveProperty('src', 'https://panoptes-uploads.zooniverse.org/subject_location/66fd834d-b13a-40f0-b97d-6d364841d56c.jpeg')
})

/*
TODO: this isn't working, as the SubjectImage seems to continue updating outside the act()
Perhaps we need to implement useSWR (https://github.com/zooniverse/community-catalog/pull/131) (to avoid using setState in useEffect), or look into other patterns that handle async calls.
(@shaunanoordin 20230826)
Comment on lines +50 to +52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React 18 has a new, concurrent data-fetching model. They recommend using a framework like Remix or NextJS, rather than implementing your own data-fetching model (Alice and FEM both have examples of how that can go wrong.)

https://react.dev/blog/2022/03/29/react-v18#suspense-in-data-frameworks


test('should render an image, when given a subject ID', function () {
act(() => {
render(
<SubjectImage
subjectId='87892456'
/>
)
})

// For multi-image subjects, SubjectImage will render the FIRST image.
expect(screen.getByRole('img')).toHaveProperty('alt', 'Preview image for Subject 87892456')
expect(screen.getByRole('img')).toHaveProperty('src', 'https://panoptes-uploads.zooniverse.org/subject_location/66fd834d-b13a-40f0-b97d-6d364841d56c.jpeg')
})
/**/

test('should render a placeholder image, when given no subject', function () {
render(
<SubjectImage />
)

const svg = screen.getByLabelText('Placeholder for Subject image')
expect(svg).toBeDefined()

const path = svg.querySelector('path')
expect(path).toHaveAttribute('d', 'M1 3h22v18H1V3zm5 6a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm17 6-5-6-6 7-3-3-8 8')
expect(path).toHaveAttribute('fill', 'none')
expect(path).toHaveAttribute('stroke', '#000')
expect(path).toHaveAttribute('stroke-width', '2')
})
})
6 changes: 4 additions & 2 deletions src/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import SubjectPage from '@src/pages/SubjectPage'
import getEnv from '@src/helpers/getEnv.js'
import getQuery from '@src/helpers/getQuery.js'

export const router = createBrowserRouter([
export const routes = [
{
path: '/',
element: <App />,
Expand Down Expand Up @@ -77,4 +77,6 @@ export const router = createBrowserRouter([
}
]
},
])
]

export const router = createBrowserRouter(routes)