使用 React 和 TDD 创建计算器应用程序
让我们使用测试优先方法和React构建一个简单的 Web 应用计算器!
如果你已经熟悉 React,并且想要提高你的测试水平,我强烈建议你进行这个练习。🌟
我们将构建一个简单的应用程序,仅包含基本的计算功能 - 但最重要的是 - 我们将遵循测试驱动开发方法。
测试驱动开发 (TDD) 是一种开发技术,在编写新的功能代码之前,你必须先编写一个会失败的测试。TDD 正被敏捷软件开发人员迅速应用于应用程序源代码的开发,甚至也被敏捷数据库管理员应用于数据库开发。( agiledata.org )
图片取自agiledata.org。
设置
我们想要一个快速设置,提供已经配置好的测试,所以我们选择旧的create-react-app
。我们还将选择TypeScript
模板。打开终端并运行:
npx create-react-app calculator --template typescript
cd calculator
打开编辑器。我将使用 VS Code:code .
打开两个终端窗口:
- 一个运行
npm start
- 一个运行
npm run test
让我们开始编码
你说开始写代码?真巧。我以为我们在做 TDD 呢。当然,我们得先写个失败测试。可是,我们从哪儿开始呢?
转到App.test.tsx
,删除现有测试并写入:
test("renders calculator", () => {
render(<App />);
const calculatorElement = screen.getByText(/calculator/i);
expect(calculatorElement).toBeInTheDocument();
});
就这样,运行测试的终端应该输出:Tests: 1 failed, 1 total
我们的测试只是简单地检查我们的应用程序中是否有某个地方呈现了文本计算器。
因此我们将创建一个Calculator.tsx
文件:
const Calculator = () => <h1>Calculator</h1>;
export default Calculator;
测试仍然失败。好吧……我们还没有渲染Calculator
组件。我们来解决这个问题。转到“App.tsx”:
import React from "react";
import "./App.css";
import Calculator from "./Calculator";
function App() {
return (
<div className="App">
<main>
<Calculator />
</main>
</div>
);
}
export default App;
✅——Tests: 1 passed, 1 total
太棒了,我们通过了第一次测试。接下来怎么办?
我们要编写计算器的代码吗?
❌当然不是,我们在“Calculator.test.tsx”中编写了另一个失败测试,以显示计算器的数字:
import { render, screen } from "@testing-library/react";
import React from "react";
import Calculator from "./Calculator";
describe("<Calculator />", () => {
it("shows numbers", () => {
render(<Calculator />);
const numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
numbers.forEach((n) => {
expect(screen.getByText(n.toString())).toBeInTheDocument();
});
});
});
并且Calculator.tsx
:
+ const numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
+ const Calculator = () => {
+ return (
+ <div className="calculator">
<h1>Calculator</h1>
+ {numbers.map((n) => (
+ <button key={n}>{n.toString()}</button>
+ ))}
+ </div>
+ );
+ };
export default Calculator;
渲染数字行
我们希望按行显示数字:
第 1 行:[7, 8, 9]
第 2 行:[4, 5, 6]
第 3 行:[1, 2, 3]
第 4 行:[0]
嗯……我们该怎么测试呢?在“Calculator.test.tsx”中,我们可以创建一个新的测试,如下所示:
it("shows 4 rows", () => {
render(<Calculator />);
const rows = screen.getAllByRole("row");
expect(rows).toHaveLength(4);
});
好的,现在我们有了失败的测试,要通过它:
const rows = [[7, 8, 9], [4, 5, 6], [1, 2, 3], [0]];
const Calculator = () => {
return (
<div className="calculator">
<h1>Calculator</h1>
<div role="grid">
{rows.map((row) => {
return (
<div key={row.toString()} role="row">
{row.map((n) => (
<button key={n}>{n.toString()}</button>
))}
</div>
);
})}
</div>
</div>
);
};
export default Calculator;
显示计算器运算符
测试显示操作员:
it("shows calculation operators", () => {
render(<Calculator />);
const calcOperators = ["+", "-", "×", "÷"];
calcOperators.forEach((operator) => {
expect(screen.getByText(operator.toString())).toBeInTheDocument();
});
});
通过测试:
const rows = [[7, 8, 9], [4, 5, 6], [1, 2, 3], [0]];
+ const calcOperators = ["+", "-", "×", "÷"];
const Calculator = () => {
return (
<div className="calculator">
<h1>Calculator</h1>
<div role="grid">
{rows.map((row) => {
return (
<div key={row.toString()} role="row">
{row.map((n) => (
<button key={n}>{n.toString()}</button>
))}
</div>
);
})}
+ {calcOperators.map((c) => (
+ <button key={c}>{c.toString()}</button>
+ ))}
</div>
</div>
);
};
export default Calculator;
显示等号和清除符号:
测试:
it("renders equal", () => {
render(<Calculator />);
const equalSign = "=";
expect(screen.getByText(equalSign)).toBeInTheDocument();
});
it("renders clear sign", () => {
render(<Calculator />);
const clear = "C";
expect(screen.getByText(clear)).toBeInTheDocument();
});
太好了,有 2 个测试失败了。解决方法:
import { Fragment } from "react";
const rows = [[7, 8, 9], [4, 5, 6], [1, 2, 3], [0]];
const calcOperators = ["+", "-", "×", "÷"];
const equalSign = "=";
const clear = "C";
const Calculator = () => {
return (
<div className="calculator">
<h1>Calculator</h1>
<div role="grid">
{rows.map((row, i) => {
return (
<Fragment key={row.toString()}>
<div role="row">
{i === 3 && <button>{clear}</button>}
{row.map((n) => (
<button key={n}>{n}</button>
))}
{i === 3 && <button>{equalSign}</button>}
</div>
</Fragment>
);
})}
{calcOperators.map((c) => (
<button key={c}>{c.toString()}</button>
))}
</div>
</div>
);
};
export default Calculator;
显示要计算的值的输入
测试:
it("renders an input", () => {
render(<Calculator />);
expect(screen.getByPlaceholderText("calculate")).toBeInTheDocument();
});
我们始终希望禁用此输入,因此我们还将为此添加一个测试:
it("renders an input disabled", () => {
render(<Calculator />);
expect(screen.getByPlaceholderText("calculate")).toBeDisabled();
});
实现输入:
+ import { Fragment, useState } from "react";
const rows = [[7, 8, 9], [4, 5, 6], [1, 2, 3], [0]];
const calcOperators = ["+", "-", "×", "÷"];
const equalSign = "=";
const clear = "C";
const Calculator = () => {
+ const [value, setValue] = useState("");
return (
<div className="calculator">
<h1>Calculator</h1>
+ <input
+ type="text"
+ defaultValue={value}
+ placeholder="calculate"
+ disabled
+ />
<div role="grid">
{rows.map((row, i) => {
return (
<Fragment key={row.toString()}>
<div role="row">
{i === 3 && <button>{clear}</button>}
{row.map((n) => (
<button key={n}>{n}</button>
))}
{i === 3 && <button>{equalSign}</button>}
</div>
</Fragment>
);
})}
{calcOperators.map((c) => (
<button key={c}>{c.toString()}</button>
))}
</div>
</div>
);
};
export default Calculator;
让它显示用户的输入
测试:
it("displays users inputs", async () => {
render(<Calculator />);
const one = screen.getByText("1");
const two = screen.getByText("2");
const plus = screen.getByText("+");
fireEvent.click(one);
fireEvent.click(plus);
fireEvent.click(two);
const result = await screen.findByPlaceholderText("calculate");
// @ts-ignore
expect(result.value).toBe("1+2");
});
it("displays multiple users inputs", async () => {
render(<Calculator />);
const one = screen.getByText("1");
const two = screen.getByText("2");
const three = screen.getByText("3");
const five = screen.getByText("5");
const divide = screen.getByText("÷");
const mul = screen.getByText("×");
const minus = screen.getByText("-");
fireEvent.click(three);
fireEvent.click(mul);
fireEvent.click(two);
fireEvent.click(minus);
fireEvent.click(one);
fireEvent.click(divide);
fireEvent.click(five);
const result = await screen.findByPlaceholderText("calculate");
// @ts-ignore
expect(result.value).toBe("3×2-1÷5");
});
通过测试:
<div role="row">
{i === 3 && <button>{clear}</button>}
{row.map((n) => (
- <button key={n}>{n}</button>
+ <button
+ onClick={() => setValue(value.concat(n.toString()))}
+ key={n}
+ >
+ {n}
+ </button>
))}
{i === 3 && <button>{equalSign}</button>}
</div>
{calcOperators.map((c) => (
- <button key={c}>{c.toString()}</button>
+ <button onClick={() => setValue(value.concat(c))} key={c}>
+ {c.toString()}
+ </button>
))}
能计算吗?
好的,到目前为止我们只是编写了一些测试来检查我们的计算器是否显示正确的内容,但是让我们编写一些测试来实际计算一些东西:
it("calculate based on users inputs", async () => {
render(<Calculator />);
const one = screen.getByText("1");
const two = screen.getByText("2");
const plus = screen.getByText("+");
const equal = screen.getByText("=");
fireEvent.click(one);
fireEvent.click(plus);
fireEvent.click(two);
fireEvent.click(equal);
const result = await screen.findByPlaceholderText("calculate");
expect(
(result as HTMLElement & {
value: string;
}).value
).toBe("3");
});
it("calculate based on multiple users inputs", async () => {
render(<Calculator />);
const one = screen.getByText("1");
const two = screen.getByText("2");
const three = screen.getByText("3");
const five = screen.getByText("5");
const divide = screen.getByText("÷");
const mul = screen.getByText("×");
const minus = screen.getByText("-");
const equal = screen.getByText("=");
fireEvent.click(three);
fireEvent.click(mul);
fireEvent.click(two);
fireEvent.click(minus);
fireEvent.click(one);
fireEvent.click(divide);
fireEvent.click(five);
fireEvent.click(equal);
const result = await screen.findByPlaceholderText("calculate");
expect(
(result as HTMLElement & {
value: string;
}).value
).toBe("5.8");
});
请注意,在第二个测试中,我们还检查操作是否按正确的顺序执行:3*2-1÷5 = 6-0.2 = 5.8
然后,让我们把这个测试通过。在这个阶段,我们可以使用 unsafeeval
函数,稍后我们会对其进行重构。记住,我们只需要通过测试。我们随时可以编写测试来指出当前实现不合格的原因。
const rows = [[7, 8, 9], [4, 5, 6], [1, 2, 3], [0]];
const calcOperators = ["+", "-", "×", "÷"];
const equalSign = "=";
const clear = "C";
+
+const calculateExpression = (expression: string) => {
+ const mulRegex = /×/g;
+ const divRegex = /÷/g;
+
+ const toEvaluate = expression.replace(mulRegex, "*").replace(divRegex, "/");
+
+ // todo - refactor eval later
+ const result = eval(toEvaluate);
+ return result;
+};
+
const Calculator = () => {
const [value, setValue] = useState("");
+
+ const calculate = () => {
+ const results = calculateExpression(value);
+ setValue(results);
+ };
+
return (
<div className="calculator">
<h1>Calculator</h1>
@@ -29,7 +47,7 @@ const Calculator = () => {
{n}
</button>
))}
- {i === 3 && <button>{equalSign}</button>}
+ {i === 3 && <button onClick={calculate}>{equalSign}</button>}
</div>
</Fragment>
);
可以使用清除按钮
测试:
it("can clear results", async () => {
render(<Calculator />);
const one = screen.getByText("1");
const two = screen.getByText("2");
const plus = screen.getByText("+");
const clear = screen.getByText("C");
fireEvent.click(one);
fireEvent.click(plus);
fireEvent.click(two);
fireEvent.click(clear);
const result = await screen.findByPlaceholderText("calculate");
expect(
(result as HTMLElement & {
value: string;
}).value
).toBe("");
});
简单的:
const Calculator = () => {
setValue(results);
};
+
+ const clearValue = () => setValue("");
+
return (
<div className="calculator">
<h1>Calculator</h1>
@@ -38,7 +40,7 @@ const Calculator = () => {
return (
<Fragment key={row.toString()}>
<div role="row">
- {i === 3 && <button>{clear}</button>}
+ {i === 3 && <button onClick={clearValue}>{clear}</button>}
{row.map((n) => (
回到计算
好的,现在我们可能想测试更多计算函数的场景。所以我觉得直接在calculateExpression
函数上写测试更合理。
因此我们将导出它并编写一些额外的测试:
describe("calculateExpression", () => {
it("correctly computes for 2 numbers with +", () => {
expect(calculateExpression("1+1")).toBe(2);
expect(calculateExpression("10+10")).toBe(20);
expect(calculateExpression("11+345")).toBe(356);
});
it("correctly substracts 2 numbers", () => {
expect(calculateExpression("1-1")).toBe(0);
expect(calculateExpression("10-1")).toBe(9);
expect(calculateExpression("11-12")).toBe(-1);
});
it("correctly multiples 2 numbers", () => {
expect(calculateExpression("1×1")).toBe(1);
expect(calculateExpression("10×0")).toBe(0);
expect(calculateExpression("11×-12")).toBe(-132);
});
it("correctly divides 2 numbers", () => {
expect(calculateExpression("1÷1")).toBe(1);
expect(calculateExpression("10÷2")).toBe(5);
expect(calculateExpression("144÷12")).toBe(12);
});
it("division by 0 returns 0 and logs exception", () => {
const errorSpy = jest.spyOn(console, "error");
expect(calculateExpression("1÷0")).toBe(undefined);
expect(errorSpy).toHaveBeenCalledTimes(1);
});
});
我们的测试仍然通过了,除了除数为 0 的那个。这很好。我们来修复它。
-const calculateExpression = (expression: string) => {
+export const calculateExpression = (expression: string) => {
const mulRegex = /×/g;
const divRegex = /÷/g;
+ const divideByZero = /\/0/g;
const toEvaluate = expression.replace(mulRegex, "*").replace(divRegex, "/");
- // todo - refactor eval later
- const result = eval(toEvaluate);
- return result;
+ try {
+ if (divideByZero.test(toEvaluate)) {
+ throw new Error("Can not divide by 0!");
+ }
+
+ // todo - refactor eval later
+ const result = eval(toEvaluate);
+
+ return result;
+ } catch (err) {
+ console.error(err);
+ return undefined;
+ }
};
好的,针对一些额外的情况进行更多测试:
it("handles multiple operations", () => {
expect(calculateExpression("1÷1×2×2+3×22")).toBe(70);
});
it("handles trailing operator", () => {
expect(calculateExpression("1÷1×2×2+3×22+")).toBe(70);
});
it("handles empty expression", () => {
expect(calculateExpression("")).toBe(undefined);
});
饮水机🌊
好吧,如果你坚持到了这里,恭喜你!🙌你正在学习如何以 TDD 的方式编写代码。
请注意,此时的思路是添加测试,然后看看哪些测试会失败。也许有些会通过,有些会失败,但我们希望确保采用测试优先的方法,并严格控制测试的质量。如果我们的测试良好,并且所有测试都通过,那么应用程序的性能就会很好。
因此让我们修复现在的两个失败测试:
const clear = "C";
+const getLastChar = (str: string) => (str.length ? str[str.length - 1] : "");
+const isNumber = (str: string) => !isNaN(Number(str));
+
export const calculateExpression = (expression: string) => {
+ if (!expression || expression.length === 0) {
+ return;
+ }
+
const mulRegex = /×/g;
const divRegex = /÷/g;
const divideByZero = /\/0/g;
- const toEvaluate = expression.replace(mulRegex, "*").replace(divRegex, "/");
+ let toEvaluate = expression.replace(mulRegex, "*").replace(divRegex, "/");
try {
if (divideByZero.test(toEvaluate)) {
throw new Error("Can not divide by 0!");
}
+
+ const lastCharaterIsNumber = isNumber(getLastChar(toEvaluate));
+
+ if (!lastCharaterIsNumber) {
+ toEvaluate = toEvaluate.slice(0, -1);
+ }
+
摆脱 eval
还记得我们说过要把 eval 改成别的吗?没错,我们确实想避免使用它,因为我们的 linter 和常识都告诉我们不应该使用它。
幸运的是,有一个包可以满足我们的要求。将字符串作为表达式传递,然后安全地对其进行求值:yarn add mathjs @types/mathjs
+import { evaluate } from "mathjs";
- // todo - refactor eval later
- const result = eval(toEvaluate);
+ const result = evaluate(toEvaluate);
什么……我们的测试还通过了?太棒了!
为应用程序设置样式
但是这个应用程序真的很丑......让我们修复它。
首先,让我们声明一些变量index.css
:
:root {
--theme-color-dark-10: #006ba1;
--theme-color-dark-20: #005a87;
--theme-color-background: #fed800;
}
在正文中,我们只需添加背景颜色,其余样式保持不变:
body {
+ background-color: var(--theme-color-background);
我们需要添加一些结构Calculator.tsx
:
import { Fragment, useState } from "react";
import { evaluate } from "mathjs";
import "./Calculator.css";
const rows = [[7, 8, 9], [4, 5, 6], [1, 2, 3], [0]];
const calcOperators = ["+", "-", "×", "÷"];
const equalSign = "=";
const clear = "C";
const getLastChar = (str: string) => (str.length ? str[str.length - 1] : "");
const isNumber = (str: string) => !isNaN(Number(str));
export const calculateExpression = (expression: string) => {
if (!expression || expression.length === 0) {
return;
}
const mulRegex = /×/g;
const divRegex = /÷/g;
const divideByZero = /\/0/g;
let toEvaluate = expression.replace(mulRegex, "*").replace(divRegex, "/");
try {
if (divideByZero.test(toEvaluate)) {
throw new Error("Can not divide by 0!");
}
const lastCharaterIsNumber = isNumber(getLastChar(toEvaluate));
if (!lastCharaterIsNumber) {
toEvaluate = toEvaluate.slice(0, -1);
}
const result = evaluate(toEvaluate);
return result;
} catch (err) {
console.error(err);
return undefined;
}
};
const Calculator = () => {
const [value, setValue] = useState("");
const calculate = () => {
const results = calculateExpression(value);
setValue(results);
};
const clearValue = () => setValue("");
return (
<div className="calculator">
<h1>Calculator</h1>
<input
type="text"
defaultValue={value}
placeholder="calculate"
disabled
/>
<div className="calculator-buttons-container">
<div role="grid">
{rows.map((row, i) => {
return (
<Fragment key={row.toString()}>
<div role="row">
{i === 3 && <button onClick={clearValue}>{clear}</button>}
{row.map((n) => (
<button
key={n}
onClick={() => setValue(value.concat(n.toString()))}
>
{n}
</button>
))}
{i === 3 && <button onClick={calculate}>{equalSign}</button>}
</div>
</Fragment>
);
})}
</div>
<div className="calculator-operators">
{calcOperators.map((c) => (
<button key={c} onClick={() => setValue(value.concat(c))}>
{c.toString()}
</button>
))}
</div>
</div>
</div>
);
};
export default Calculator;
我们还将添加一个Calculator.css
文件,其中包含:
.calculator > h1 {
color: var(--theme-color-dark-20);
text-transform: uppercase;
}
.calculator input {
height: 2.5rem;
width: 13rem;
padding: 0.4rem;
border: 1px solid white;
margin: 0.3rem 0.3rem 1.5rem 0.3rem;
font-size: 1.5rem;
color: var(--theme-color-dark-20);
box-shadow: 8px 8px 5px -7px var(--theme-color-dark-10);
}
.calculator button {
width: 3.5rem;
height: 3.5rem;
font-size: 1.5rem;
color: var(--theme-color-dark-20);
}
.calculator-buttons-container {
display: flex;
align-items: center;
justify-content: center;
}
.calculator-operators {
display: flex;
flex-direction: column;
}
结论
我想就此打住 - 不过,该应用程序仍然存在一些错误和可以修复的问题。
如果您愿意,请以 TDD 风格修复它们🔥。
这是本次编码练习的repo。
鏂囩珷鏉ユ簮锛�https://dev.to/alexandrudanpop/creating-a-calculator-app-with-react-and-tdd-277