feat: add frontend unit test framework (#6426)

This commit is contained in:
Joel 2024-07-18 17:35:10 +08:00 committed by GitHub
parent afe95fa780
commit 4ae4895ebe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 2439 additions and 21 deletions

6
web/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"recommendations": [
"bradlc.vscode-tailwindcss",
"firsttris.vscode-jest-runner"
]
}

View File

@ -74,6 +74,25 @@ npm run start --port=3001 --host=0.0.0.0
If your IDE is VSCode, rename `web/.vscode/settings.example.json` to `web/.vscode/settings.json` for lint code setting. If your IDE is VSCode, rename `web/.vscode/settings.example.json` to `web/.vscode/settings.json` for lint code setting.
## Test
We start to use [Jest](https://jestjs.io/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) for Unit Testing.
You can create a test file with a suffix of `.spec` beside the file that to be tested. For example, if you want to test a file named `util.ts`. The test file name should be `util.spec.ts`.
Run test:
```bash
npm run test
```
If you are not familiar with writing tests, here is some code to refer to:
* [classnames.spec.ts](./utils/classnames.spec.ts)
* [index.spec.tsx](./app/components/base/button/index.spec.tsx)
## Documentation ## Documentation
Visit <https://docs.dify.ai/getting-started/readme> to view the full documentation. Visit <https://docs.dify.ai/getting-started/readme> to view the full documentation.

View File

@ -0,0 +1,49 @@
import React from 'react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import Button from './index'
afterEach(cleanup)
// https://testing-library.com/docs/queries/about
describe('Button text', () => {
test('Button text should be same as children', async () => {
const { getByRole, container } = render(<Button>Click me</Button>)
expect(getByRole('button').textContent).toBe('Click me')
expect(container.querySelector('button')?.textContent).toBe('Click me')
})
test('Loading button text should include same as children', async () => {
const { getByRole } = render(<Button loading>Click me</Button>)
expect(getByRole('button').textContent?.includes('Loading')).toBe(true)
})
})
describe('Button style', () => {
test('Button should have default variant', async () => {
const { getByRole } = render(<Button>Click me</Button>)
expect(getByRole('button').className).toContain('btn-secondary')
})
test('Button should have primary variant', async () => {
const { getByRole } = render(<Button variant='primary'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-primary')
})
test('Button should have warning variant', async () => {
const { getByRole } = render(<Button variant='warning'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-warning')
})
test('Button disabled should have disabled variant', async () => {
const { getByRole } = render(<Button disabled>Click me</Button>)
expect(getByRole('button').className).toContain('btn-disabled')
})
})
describe('Button events', () => {
test('onClick should been call after clicked', async () => {
const onClick = jest.fn()
const { getByRole } = render(<Button onClick={onClick}>Click me</Button>)
fireEvent.click(getByRole('button'))
expect(onClick).toHaveBeenCalled()
})
})

View File

@ -45,7 +45,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props} {...props}
> >
{children} {children}
<Spinner loading={loading} className='!text-white !h-3 !w-3 !border-2 !ml-1' /> {loading && <Spinner loading={loading} className='!text-white !h-3 !w-3 !border-2 !ml-1' />}
</button> </button>
) )
}, },

208
web/jest.config.ts Normal file
View File

@ -0,0 +1,208 @@
/**
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
import type { Config } from 'jest'
import nextJest from 'next/jest.js'
// https://nextjs.org/docs/app/building-your-application/testing/jest
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
})
const config: Config = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/9c/7gly5yl90qxdjljqsvkk758h0000gn/T/jest_dx",
// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// coverageDirectory: "coverage",
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: 'v8',
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// The default configuration for fake timers
// fakeTimers: {
// "enableGlobally": false
// },
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "mjs",
// "cjs",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
moduleNameMapper: {
'^@/components/(.*)$': '<rootDir>/components/$1',
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: 'jsdom',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
}
export default createJestConfig(config)

View File

@ -15,7 +15,9 @@
"prepare": "cd ../ && node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky install ./web/.husky", "prepare": "cd ../ && node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky install ./web/.husky",
"gen-icons": "node ./app/components/base/icons/script.js", "gen-icons": "node ./app/components/base/icons/script.js",
"uglify-embed": "node ./bin/uglify-embed", "uglify-embed": "node ./bin/uglify-embed",
"check-i18n": "node ./i18n/script.js" "check-i18n": "node ./i18n/script.js",
"test": "jest",
"test:watch": "jest --watch"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.22.3", "@babel/runtime": "^7.22.3",
@ -102,8 +104,12 @@
"@antfu/eslint-config": "^0.36.0", "@antfu/eslint-config": "^0.36.0",
"@faker-js/faker": "^7.6.0", "@faker-js/faker": "^7.6.0",
"@rgrove/parse-xml": "^4.1.0", "@rgrove/parse-xml": "^4.1.0",
"@testing-library/dom": "^10.3.2",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^16.0.0",
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/dagre": "^0.7.52", "@types/dagre": "^0.7.52",
"@types/jest": "^29.5.12",
"@types/js-cookie": "^3.0.3", "@types/js-cookie": "^3.0.3",
"@types/lodash-es": "^4.17.7", "@types/lodash-es": "^4.17.7",
"@types/negotiator": "^0.6.1", "@types/negotiator": "^0.6.1",
@ -124,10 +130,13 @@
"eslint": "^8.36.0", "eslint": "^8.36.0",
"eslint-config-next": "^14.0.4", "eslint-config-next": "^14.0.4",
"husky": "^8.0.3", "husky": "^8.0.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lint-staged": "^13.2.2", "lint-staged": "^13.2.2",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"sass": "^1.61.0", "sass": "^1.61.0",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"ts-node": "^10.9.2",
"typescript": "4.9.5", "typescript": "4.9.5",
"uglify-js": "^3.17.4" "uglify-js": "^3.17.4"
}, },

View File

@ -0,0 +1,55 @@
import cn from './classnames'
describe('classnames', () => {
test('classnames libs feature', () => {
expect(cn('foo')).toBe('foo')
expect(cn('foo', 'bar')).toBe('foo bar')
expect(cn(['foo', 'bar'])).toBe('foo bar')
expect(cn(undefined)).toBe('')
expect(cn(null)).toBe('')
expect(cn(false)).toBe('')
expect(cn({
foo: true,
bar: false,
baz: true,
})).toBe('foo baz')
})
test('tailwind-merge', () => {
expect(cn('p-0')).toBe('p-0')
expect(cn('text-right text-center text-left')).toBe('text-left')
expect(cn('pl-4 p-8')).toBe('p-8')
expect(cn('m-[2px] m-[4px]')).toBe('m-[4px]')
expect(cn('m-1 m-[4px]')).toBe('m-[4px]')
expect(cn('overflow-x-auto hover:overflow-x-hidden overflow-x-scroll')).toBe(
'hover:overflow-x-hidden overflow-x-scroll',
)
expect(cn('h-10 h-min')).toBe('h-min')
expect(cn('bg-grey-5 bg-hotpink')).toBe('bg-hotpink')
expect(cn('hover:block hover:inline')).toBe('hover:inline')
expect(cn('font-medium !font-bold')).toBe('font-medium !font-bold')
expect(cn('!font-medium !font-bold')).toBe('!font-bold')
expect(cn('text-gray-100 text-primary-200')).toBe('text-primary-200')
expect(cn('text-some-unknown-color text-components-input-bg-disabled text-primary-200')).toBe('text-primary-200')
expect(cn('bg-some-unknown-color bg-components-input-bg-disabled bg-primary-200')).toBe('bg-primary-200')
expect(cn('border-t border-white/10')).toBe('border-t border-white/10')
expect(cn('border-t border-white')).toBe('border-t border-white')
expect(cn('text-3.5xl text-black')).toBe('text-3.5xl text-black')
})
test('classnames combined with tailwind-merge', () => {
expect(cn('text-right', {
'text-center': true,
})).toBe('text-center')
expect(cn('text-right', {
'text-center': false,
})).toBe('text-right')
})
})

File diff suppressed because it is too large Load Diff