Преглед изворни кода

feat: add frontend unit test framework (#6426)

Joel пре 8 месеци
родитељ
комит
4ae4895ebe

+ 6 - 0
web/.vscode/extensions.json

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

+ 19 - 0
web/README.md

@@ -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.
 
+## 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
 
 Visit <https://docs.dify.ai/getting-started/readme> to view the full documentation.

+ 49 - 0
web/app/components/base/button/index.spec.tsx

@@ -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()
+  })
+})

+ 1 - 1
web/app/components/base/button/index.tsx

@@ -45,7 +45,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
         {...props}
       >
         {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>
     )
   },

+ 208 - 0
web/jest.config.ts

@@ -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)

+ 10 - 1
web/package.json

@@ -15,7 +15,9 @@
     "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",
     "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": {
     "@babel/runtime": "^7.22.3",
@@ -102,8 +104,12 @@
     "@antfu/eslint-config": "^0.36.0",
     "@faker-js/faker": "^7.6.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/dagre": "^0.7.52",
+    "@types/jest": "^29.5.12",
     "@types/js-cookie": "^3.0.3",
     "@types/lodash-es": "^4.17.7",
     "@types/negotiator": "^0.6.1",
@@ -124,10 +130,13 @@
     "eslint": "^8.36.0",
     "eslint-config-next": "^14.0.4",
     "husky": "^8.0.3",
+    "jest": "^29.7.0",
+    "jest-environment-jsdom": "^29.7.0",
     "lint-staged": "^13.2.2",
     "postcss": "^8.4.31",
     "sass": "^1.61.0",
     "tailwindcss": "^3.4.4",
+    "ts-node": "^10.9.2",
     "typescript": "4.9.5",
     "uglify-js": "^3.17.4"
   },

+ 55 - 0
web/utils/classnames.spec.ts

@@ -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')
+  })
+})

Разлика између датотеке није приказан због своје велике величине
+ 846 - 4
web/yarn.lock


Неке датотеке нису приказане због велике количине промена