Skip to content

Commit bf453a8

Browse files
authored
Merge pull request #1 from TD-gaowei/main
1111
2 parents c11cbe8 + c6671c6 commit bf453a8

22 files changed

+627
-15
lines changed

.github/workflows/ci.yml

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: CI - Unit Tests & Coverage
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main # 或者你用于主开发分支的其他名称
7+
push:
8+
branches:
9+
- main
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Checkout code
17+
uses: actions/checkout@v3
18+
19+
- name: Set up Node.js
20+
uses: actions/setup-node@v3
21+
with:
22+
node-version: "20"
23+
24+
- name: Install dependencies
25+
run: npm install --legacy-peer-deps
26+
27+
- name: Run unit tests and collect coverage
28+
run: npm run test:coverage
29+
env:
30+
CI: true
31+
32+
- name: Debug coverage files
33+
run: ls -R coverage
34+
35+
- name: Upload coverage to Codecov
36+
uses: codecov/codecov-action@v5
37+
with:
38+
token: ${{ secrets.CODECOV_TOKEN }}
39+
slug: TD-gaowei/unit-test-react
40+
files: ./coverage/lcov.info # 修改为你实际的覆盖率报告路径
41+
fail_ci_if_error: true
42+
root_dir: /home/runner/work/unit-test-react/unit-test-react
43+
verbose: true
44+
flags: unittests
45+
# name: codecov-umbrella
46+
47+
- name: Fail the PR if coverage drops in the new commit
48+
run: |
49+
COMMIT_SHA=${{ github.event.pull_request.head.sha }}
50+
coverage=$(curl -s https://codecov.io/api/gh/${{ github.repository }}/commit/$COMMIT_SHA \
51+
| jq -r '.commit.totals.coverage')
52+
53+
echo "Current coverage: $coverage"
54+
55+
if [[ -z "$coverage" || $(echo "$coverage < 80" | bc) -eq 1 ]]; then
56+
echo "Coverage is below 80% or not found, failing PR"
57+
exit 1
58+
fi

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,4 @@ dist-ssr
2424
*.sw?
2525

2626
node_modules
27-
coverage
27+
# coverage

README.md

+11-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
- 工具方法测试 ✅
2+
- 组件 UI 测试 ✅
3+
- 组件 UI 交互测试 ✅
4+
- React Hooks 测试 ✅. -> 本质上是函数测试
15

6+
如果可以用当作 hooks 测试,那就用 Hooks 的方式测试,要么可以当下组件中进行测试
27

3-
- 工具方法测试
4-
- 组件UI测试
5-
- 组件UI交互测试
6-
- React Hooks测试
8+
- 使用 Zustand 的 hooks 测试 ✅
9+
- 快照测试 ✅
10+
- 异步测试 ✅
11+
- 异常测试 ✅
12+
- 代码覆盖率测试 ✅
13+
- 边界测试 - 本质上是多种输入不同参数测试的组合 ✅

package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88
"lint": "eslint .",
99
"preview": "vite preview",
1010
"test": "vitest",
11-
"test:coverage": "vitest --coverage"
11+
"test:coverage": "npx c8 --reporter=lcov vitest"
1212
},
1313
"dependencies": {
1414
"@testing-library/jest-dom": "^6.6.3",
1515
"react": "18.3.1",
16-
"react-dom": "18.3.1"
16+
"react-dom": "18.3.1",
17+
"zustand": "^5.0.3"
1718
},
1819
"devDependencies": {
1920
"@eslint/js": "^9.21.0",
@@ -24,6 +25,7 @@
2425
"@types/react-dom": "^19.0.4",
2526
"@vitejs/plugin-react": "^4.3.4",
2627
"@vitest/coverage-v8": "3.0.8",
28+
"c8": "^10.1.3",
2729
"eslint": "^9.21.0",
2830
"eslint-plugin-react-hooks": "^5.1.0",
2931
"eslint-plugin-react-refresh": "^0.4.19",

src/App.jsx

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { useState } from "react";
22
import reactLogo from "./assets/react.svg";
33

44
import "./App.css";
5+
import Login from "./components/Login/Login";
6+
import UserList from "./components/UserList/UserList";
57

68
function App() {
79
const [count, setCount] = useState(0);
@@ -25,6 +27,9 @@ function App() {
2527
<p className="read-the-docs">
2628
Click on the Vite and React logos to learn more
2729
</p>
30+
31+
<Login />
32+
<UserList />
2833
</>
2934
);
3035
}
+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React, { useState, useEffect } from "react";
2+
3+
export default function AccountList() {
4+
const [users, setUsers] = useState([]);
5+
const [error, setError] = useState(null);
6+
7+
useEffect(() => {
8+
// 模拟API请求
9+
async function fetchUsers() {
10+
try {
11+
const response = await fetch("/api/users");
12+
if (!response.ok) {
13+
throw new Error("Failed to fetch users");
14+
}
15+
const data = await response.json();
16+
setUsers(data);
17+
} catch (err) {
18+
setError(err.message);
19+
}
20+
}
21+
22+
fetchUsers();
23+
}, []);
24+
25+
if (error) {
26+
return <div>Error: {error}</div>;
27+
}
28+
29+
return (
30+
<ul>
31+
{users.map((user) => (
32+
<li key={user.id}>{user.name}</li>
33+
))}
34+
</ul>
35+
);
36+
}

src/components/Button/Button.jsx

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Button({ label }) {
2+
return <button>{label}</button>;
3+
}

src/components/Login/Login.jsx

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React, { useState } from "react";
2+
3+
const Login = () => {
4+
const [username, setUsername] = useState("");
5+
const [password, setPassword] = useState("");
6+
7+
const handleSubmit = (e) => {
8+
e.preventDefault();
9+
// Handle login logic here
10+
console.log("Username:", username);
11+
console.log("Password:", password);
12+
};
13+
14+
return (
15+
<div className="login-container">
16+
<form onSubmit={handleSubmit}>
17+
<div className="form-group">
18+
<label htmlFor="username">Username:</label>
19+
<input
20+
type="text"
21+
id="username"
22+
value={username}
23+
onChange={(e) => setUsername(e.target.value)}
24+
/>
25+
</div>
26+
<div className="form-group">
27+
<label htmlFor="password">Password:</label>
28+
<input
29+
type="password"
30+
id="password"
31+
value={password}
32+
onChange={(e) => setPassword(e.target.value)}
33+
/>
34+
</div>
35+
<button type="submit">登录</button>
36+
</form>
37+
</div>
38+
);
39+
};
40+
41+
export default Login;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { render, screen, fireEvent } from "@testing-library/react";
2+
3+
import Login from "../Login";
4+
import { expect } from "vitest";
5+
6+
describe("Login Component", () => {
7+
it("renders the login form", () => {
8+
render(<Login />);
9+
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
10+
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
11+
expect(screen.getByRole("button", { name: //i })).toBeInTheDocument();
12+
});
13+
14+
it("updates the username and password fields", () => {
15+
render(<Login />);
16+
const usernameInput = screen.getByLabelText(/username/i);
17+
const passwordInput = screen.getByLabelText(/password/i);
18+
19+
fireEvent.change(usernameInput, { target: { value: "testuser" } });
20+
fireEvent.change(passwordInput, { target: { value: "password123" } });
21+
22+
expect(usernameInput.value).toBe("testuser");
23+
expect(passwordInput.value).toBe("password123");
24+
});
25+
26+
it("handles form submission", () => {
27+
render(<Login />);
28+
const usernameInput = screen.getByLabelText(/username/i);
29+
const passwordInput = screen.getByLabelText(/password/i);
30+
const button = screen.getByRole("button", { name: //i });
31+
32+
const spyConsole = vi.spyOn(console, "log").mockImplementation(() => {});
33+
34+
fireEvent.change(usernameInput, { target: { value: "testuser" } });
35+
fireEvent.change(passwordInput, { target: { value: "password123" } });
36+
37+
fireEvent.click(button);
38+
39+
expect(usernameInput.value).toBe("testuser");
40+
expect(passwordInput.value).toBe("password123");
41+
42+
expect(spyConsole).toHaveBeenCalledTimes(2);
43+
});
44+
});

src/components/UserList/UserList.jsx

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import React, { useEffect, useState } from "react";
2+
3+
const mockUsers = [{ name: "Alice" }, { name: "Bob" }, { name: "Charlie" }];
4+
5+
const UserList = () => {
6+
const [users, setUsers] = useState([]);
7+
8+
useEffect(() => {
9+
setTimeout(() => {
10+
setUsers(mockUsers);
11+
}, 2000);
12+
});
13+
14+
return (
15+
<div>
16+
<h2>User List</h2>
17+
<ul>
18+
{users.map((user, index) => (
19+
<li key={index}>{user.name}</li>
20+
))}
21+
</ul>
22+
</div>
23+
);
24+
};
25+
26+
export default UserList;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// AccountList.test.js
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import AccountList from "../AccountList/AccountList";
4+
5+
// 模拟 API 请求
6+
global.fetch = vi.fn();
7+
8+
describe("AccountList", () => {
9+
it("should display users after successful fetch", async () => {
10+
// 模拟成功的 API 响应
11+
fetch.mockResolvedValueOnce({
12+
ok: true,
13+
json: async () => [
14+
{ id: 1, name: "Alice" },
15+
{ id: 2, name: "Bob" },
16+
],
17+
});
18+
19+
render(<AccountList />);
20+
21+
// 等待用户数据加载
22+
await waitFor(() => screen.getByText("Alice"));
23+
await waitFor(() => screen.getByText("Bob"));
24+
25+
// 检查用户是否渲染
26+
expect(screen.getByText("Alice")).toBeInTheDocument();
27+
expect(screen.getByText("Bob")).toBeInTheDocument();
28+
});
29+
30+
it("should display an error message when fetch fails", async () => {
31+
// 模拟失败的 API 响应
32+
fetch.mockRejectedValueOnce(new Error("Failed to fetch users"));
33+
34+
render(<AccountList />);
35+
36+
// 等待错误消息渲染
37+
await waitFor(() => screen.getByText(/Error:/));
38+
39+
// 检查错误消息
40+
expect(
41+
screen.getByText("Error: Failed to fetch users")
42+
).toBeInTheDocument();
43+
});
44+
45+
it("should display an error message when API response is not ok", async () => {
46+
// 模拟 API 返回失败的响应(非 2xx 状态)
47+
fetch.mockResolvedValueOnce({
48+
ok: false,
49+
status: 500,
50+
json: async () => ({ message: "Internal Server Error" }),
51+
});
52+
53+
render(<AccountList />);
54+
55+
// 等待错误消息渲染
56+
await waitFor(() => screen.getByText(/Error:/));
57+
58+
// 检查错误消息
59+
expect(
60+
screen.getByText("Error: Failed to fetch users")
61+
).toBeInTheDocument();
62+
});
63+
});
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Button.test.js
2+
import { render } from "@testing-library/react";
3+
import Button from "../Button/Button";
4+
5+
describe("Button Component Snapshot", () => {
6+
it("should match the snapshot", () => {
7+
// 渲染组件
8+
const { asFragment } = render(<Button label="Click Me" />);
9+
10+
// 使用快照 API 将渲染结果与之前保存的快照进行比较
11+
expect(asFragment()).toMatchSnapshot();
12+
});
13+
});
+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import UserList from "../UserList/UserList";
3+
import { expect } from "vitest";
4+
5+
describe("UserList component", () => {
6+
it("renders User List heading", () => {
7+
render(<UserList />);
8+
expect(screen.getByText("User List")).toBeInTheDocument();
9+
});
10+
11+
it("renders users after timeout", async () => {
12+
render(<UserList />);
13+
14+
const alice = await screen.findByText("Alice", {}, { timeout: 3000 });
15+
const bob = await screen.findByText("Bob", {}, { timeout: 3000 });
16+
const charlie = await screen.findByText("Charlie", {}, { timeout: 3000 });
17+
18+
expect(alice).toBeInTheDocument();
19+
expect(bob).toBeInTheDocument();
20+
expect(charlie).toBeInTheDocument();
21+
});
22+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`Button Component Snapshot > should match the snapshot 1`] = `
4+
<DocumentFragment>
5+
<button>
6+
Click Me
7+
</button>
8+
</DocumentFragment>
9+
`;

0 commit comments

Comments
 (0)