chore: Add initial jest tests + code coverage (#13)

- Adds initial infra for running front-end tests (`jest`, `ts-jest`, `jest.config.js`, etc)
- Adds codecov integration front-end code
This commit is contained in:
Bryan 2022-01-13 18:48:23 -08:00 committed by GitHub
parent afc2fa3b62
commit 423611b001
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 2484 additions and 142 deletions

View File

@ -136,7 +136,7 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./gotests.coverage
flags: ${{ matrix.os }}
flags: unittest-go-${{ matrix.os }}
fail_ci_if_error: true
test-js:
@ -161,3 +161,12 @@ jobs:
- run: yarn install
- run: yarn build
- run: yarn test:coverage
- uses: codecov/codecov-action@v2
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
flags: unittest-js
fail_ci_if_error: true

3
.gitignore vendored
View File

@ -16,4 +16,5 @@ yarn-error.log
# Front-end ignore
.next/
site/.next/
site/.next/
coverage/

View File

@ -12,4 +12,5 @@ yarn-error.log
# Front-end ignore
.next/
site/.next/
site/.next/
coverage/

26
jest.config.js Normal file
View File

@ -0,0 +1,26 @@
module.exports = {
projects: [
{
coverageReporters: ["text", "lcov"],
displayName: "test",
preset: "ts-jest",
roots: ["<rootDir>/site"],
transform: {
"^.+\\.tsx?$": "ts-jest",
},
testEnvironment: "jsdom",
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
testPathIgnorePatterns: ["/node_modules/", "/__tests__/fakes"],
moduleDirectories: ["node_modules", "<rootDir>"],
},
],
collectCoverageFrom: [
"<rootDir>/site/**/*.js",
"<rootDir>/site/**/*.ts",
"<rootDir>/site/**/*.tsx",
"!<rootDir>/site/**/*.stories.tsx",
"!<rootDir>/site/.next/**/*.*",
"!<rootDir>/site/next-env.d.ts",
"!<rootDir>/site/next.config.js",
],
}

View File

@ -8,20 +8,26 @@
"build:dev": "next build site",
"dev": "next dev site",
"format:check": "prettier --check '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'",
"format:write": "prettier --write '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'"
"format:write": "prettier --write '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'",
"test": "jest --selectProjects test",
"test:coverage": "jest --selectProjects test --collectCoverage"
},
"devDependencies": {
"@material-ui/core": "4.9.4",
"@material-ui/icons": "4.5.1",
"@material-ui/lab": "4.0.0-alpha.42",
"@testing-library/react": "12.1.2",
"@types/jest": "27.4.0",
"@types/node": "14.18.4",
"@types/react": "17.0.38",
"@types/react-dom": "17.0.11",
"@types/superagent": "4.1.14",
"jest": "27.4.7",
"next": "12.0.7",
"prettier": "2.5.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"ts-jest": "27.1.2",
"ts-loader": "9.2.6",
"typescript": "4.5.4"
}

View File

@ -0,0 +1,58 @@
import { fireEvent, render, screen } from "@testing-library/react"
import React from "react"
import { SplitButton, SplitButtonProps } from "./SplitButton"
namespace Helpers {
export type SplitButtonOptions = "a" | "b" | "c"
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
export const callback = (selectedOption: SplitButtonOptions): void => {}
export const options: SplitButtonProps<SplitButtonOptions>["options"] = [
{
label: "test a",
value: "a",
},
{
label: "test b",
value: "b",
},
{
label: "test c",
value: "c",
},
]
}
describe("SplitButton", () => {
describe("onClick", () => {
it("is called when primary action is clicked", () => {
// Given
const mockedAndSpyedCallback = jest.fn(Helpers.callback)
// When
render(<SplitButton onClick={mockedAndSpyedCallback} options={Helpers.options} />)
fireEvent.click(screen.getByText("test a"))
// Then
expect(mockedAndSpyedCallback.mock.calls.length).toBe(1)
expect(mockedAndSpyedCallback.mock.calls[0][0]).toBe("a")
})
it("is called when clicking option in pop-up", () => {
// Given
const mockedAndSpyedCallback = jest.fn(Helpers.callback)
// When
render(<SplitButton onClick={mockedAndSpyedCallback} options={Helpers.options} />)
const buttons = screen.getAllByRole("button")
const dropdownButton = buttons[1]
fireEvent.click(dropdownButton)
fireEvent.click(screen.getByText("test c"))
// Then
expect(mockedAndSpyedCallback.mock.calls.length).toBe(1)
expect(mockedAndSpyedCallback.mock.calls[0][0]).toBe("c")
})
})
})

View File

@ -0,0 +1,14 @@
import { screen } from "@testing-library/react"
import { render } from "../../test_helpers"
import React from "react"
import { EmptyState, EmptyStateProps } from "./index"
describe("EmptyState", () => {
it("renders (smoke test)", async () => {
// When
render(<EmptyState message="Hello, world" />)
// Then
await screen.findByText("Hello, world")
})
})

View File

@ -0,0 +1,22 @@
import React from "react"
import { SvgIcon } from "@material-ui/core"
import { render } from "./../../test_helpers"
import * as Icons from "./index"
const getAllIcons = (): [string, typeof SvgIcon][] => {
let k: keyof typeof Icons
let ret: [string, typeof SvgIcon][] = []
for (k in Icons) {
ret.push([k, Icons[k]])
}
return ret
}
describe("Icons", () => {
const allIcons = getAllIcons()
it.each(allIcons)(`rendering icon %p`, (_name, Icon) => {
render(<Icon />)
})
})

View File

@ -1,51 +0,0 @@
import ListItemIcon from "@material-ui/core/ListItemIcon"
import MenuItem from "@material-ui/core/MenuItem"
import { SvgIcon, Typography } from "@material-ui/core"
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
export interface NavMenuEntryProps {
icon: typeof SvgIcon
path: string
label?: string
selected: boolean
className?: string
onClick?: () => void
}
export const NavMenuEntry: React.FC<NavMenuEntryProps> = ({
className,
icon,
path,
label = path,
selected,
onClick,
}) => {
const styles = useStyles()
const Icon = icon
return (
<MenuItem selected={selected} className={className} onClick={onClick}>
<div className={styles.root}>
{icon && (
<ListItemIcon>
<Icon className={styles.icon} />
</ListItemIcon>
)}
<Typography>{label}</Typography>
</div>
</MenuItem>
)
}
const useStyles = makeStyles((theme) => ({
root: {
padding: "2em",
},
icon: {
color: theme.palette.text.primary,
"& path": {
fill: theme.palette.text.primary,
},
},
}))

View File

@ -0,0 +1,15 @@
import React from "react"
import { screen } from "@testing-library/react"
import { render } from "../../test_helpers"
import { Navbar } from "./index"
describe("Navbar", () => {
it("renders content", async () => {
// When
render(<Navbar />)
// Then
await screen.findAllByText("Coder", { exact: false })
})
})

View File

@ -18,7 +18,7 @@ export const Navbar: React.FC = () => {
</Link>
</div>
<div className={styles.fullWidth}>
<div className={styles.title}>Hello, World - Coder v2</div>
<div className={styles.title}>Coder v2</div>
</div>
<div className={styles.fixed}>
<List>

View File

@ -0,0 +1,15 @@
import React from "react"
import { screen } from "@testing-library/react"
import { render } from "../../test_helpers"
import { Footer } from "./Footer"
describe("Footer", () => {
it("renders content", async () => {
// When
render(<Footer />)
// Then
await screen.findByText("Copyright", { exact: false })
})
})

View File

@ -1,50 +1 @@
import React from "react"
import { makeStyles } from "@material-ui/core/styles"
import { Footer } from "./Footer"
import { Navbar } from "../Navbar"
export const Page: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// TODO: More interesting styling here!
const styles = useStyles()
const header = (
<div className={styles.header}>
<Navbar />
</div>
)
const footer = (
<div className={styles.footer}>
<Footer />
</div>
)
const body = <div className={styles.body}> {children}</div>
return (
<div className={styles.root}>
{header}
{body}
{footer}
</div>
)
}
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
flexDirection: "column",
},
header: {
flex: 0,
},
body: {
height: "100%",
flex: 1,
},
footer: {
flex: 0,
},
}))
export * from "./Footer"

View File

@ -3,7 +3,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require("path")
module.exports = {
env: {},

View File

@ -5,20 +5,69 @@ import ThemeProvider from "@material-ui/styles/ThemeProvider"
import { dark } from "../theme"
import { AppProps } from "next/app"
import { makeStyles } from "@material-ui/core"
import { Navbar } from "../components/Navbar"
import { Footer } from "../components/Page"
/**
* `Contents` is the wrapper around the core app UI,
* containing common UI elements like the footer and navbar.
*
* This can't be inlined in `MyApp` because it requires styling,
* and `useStyles` needs to be inside a `<ThemeProvider />`
*/
const Contents: React.FC<AppProps> = ({ Component, pageProps }) => {
const styles = useStyles()
const header = (
<div className={styles.header}>
<Navbar />
</div>
)
const footer = (
<div className={styles.footer}>
<Footer />
</div>
)
return (
<div className={styles.root}>
{header}
<Component {...pageProps} />
{footer}
</div>
)
}
/**
* <App /> is the root rendering logic of the application - setting up our router
* and any contexts / global state management.
* @returns
*/
const MyApp: React.FC<AppProps> = ({ Component, pageProps }) => {
const MyApp: React.FC<AppProps> = (appProps) => {
return (
<ThemeProvider theme={dark}>
<CssBaseline />
<Component {...pageProps} />
<Contents {...appProps} />
</ThemeProvider>
)
}
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
flexDirection: "column",
},
header: {
flex: 0,
},
body: {
height: "100%",
flex: 1,
},
footer: {
flex: 0,
},
}))
export default MyApp

View File

@ -1,8 +1,8 @@
import React, { useState } from "react"
import { Dialog, DialogActions, Button, DialogTitle, DialogContent, makeStyles, Box, Paper } from "@material-ui/core"
import React from "react"
import { makeStyles, Box, Paper } from "@material-ui/core"
import { AddToQueue as AddWorkspaceIcon } from "@material-ui/icons"
import { EmptyState, Page, SplitButton } from "../components"
import { EmptyState, SplitButton } from "../components"
const WorkspacesPage: React.FC = () => {
const styles = useStyles()
@ -17,7 +17,7 @@ const WorkspacesPage: React.FC = () => {
}
return (
<Page>
<>
<div className={styles.header}>
<SplitButton<string>
color="primary"
@ -37,12 +37,12 @@ const WorkspacesPage: React.FC = () => {
/>
</div>
<Paper style={{ maxWidth: "1380px", margin: "1em auto" }}>
<Paper style={{ maxWidth: "1380px", margin: "1em auto", width: "100%" }}>
<Box pt={4} pb={4}>
<EmptyState message="No workspaces available." button={button} />
</Box>
</Paper>
</Page>
</>
)
}

View File

@ -0,0 +1,13 @@
import { render as wrappedRender, RenderResult } from "@testing-library/react"
import React from "react"
import ThemeProvider from "@material-ui/styles/ThemeProvider"
import { dark } from "../theme"
export const WrapperComponent: React.FC = ({ children }) => {
return <ThemeProvider theme={dark}>{children}</ThemeProvider>
}
export const render = (component: React.ReactElement): RenderResult => {
return wrappedRender(<WrapperComponent>{component}</WrapperComponent>)
}

View File

@ -1,4 +1,5 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "./dist/",
"noImplicitAny": true,

10
tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"jsx": "react",
"downlevelIteration": true,
"strict": true,
"esModuleInterop": true
}
}

2253
yarn.lock

File diff suppressed because it is too large Load Diff