使用 React 和 TDD 创建计算器应用程序

2025-06-08

使用 React 和 TDD 创建计算器应用程序

让我们使用测试优先方法和React构建一个简单的 Web 应用计算器

如果你已经熟悉 React,并且想要提高你的测试水平,我强烈建议你进行这个练习。🌟

我们将构建一个简单的应用程序,仅包含基本的计算功能 - 但最重要的是 - 我们将遵循测试驱动开发方法。

测试驱动开发 (TDD) 是一种开发技术,在编写新的功能代码之前,你必须先编写一个会失败的测试。TDD 正被敏捷软件开发人员迅速应用于应用程序源代码的开发,甚至也被敏捷数据库管理员应用于数据库开发。( agiledata.org )

TDD 图
图片取自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();
});
Enter fullscreen mode Exit fullscreen mode

就这样,运行测试的终端应该输出:
Tests: 1 failed, 1 total

我们的测试只是简单地检查我们的应用程序中是否有某个地方呈现了文本计算器

因此我们将创建一个Calculator.tsx文件:

const Calculator = () => <h1>Calculator</h1>;

export default Calculator;
Enter fullscreen mode Exit fullscreen mode

测试仍然失败。好吧……我们还没有渲染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;
Enter fullscreen mode Exit fullscreen mode

✅——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();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

并且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;
Enter fullscreen mode Exit fullscreen mode

好的,此时,如果我们查看应用程序,我们会看到类似以下内容:
带有数字按钮的初始应用程序

渲染数字行

我们希望按行显示数字:
第 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);
  });
Enter fullscreen mode Exit fullscreen mode

好的,现在我们有了失败的测试,要通过它:

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;
Enter fullscreen mode Exit fullscreen mode

我们也来检查一下我们的应用程序:
正在进行的计算器应用程序

显示计算器运算符

测试显示操作员:

  it("shows calculation operators", () => {
    render(<Calculator />);
    const calcOperators = ["+", "-", "×", "÷"];

    calcOperators.forEach((operator) => {
      expect(screen.getByText(operator.toString())).toBeInTheDocument();
    });
  });
Enter fullscreen mode Exit fullscreen mode

通过测试:

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;
Enter fullscreen mode Exit fullscreen mode

太棒了!看起来糟透了😅。别担心,我们稍后会修复样式。
正在进行的计算器应用程序

显示等号和清除符号:

测试:

  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();
  });
Enter fullscreen mode Exit fullscreen mode

太好了,有 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;
Enter fullscreen mode Exit fullscreen mode

显示要计算的值的输入

测试:

  it("renders an input", () => {
    render(<Calculator />);
    expect(screen.getByPlaceholderText("calculate")).toBeInTheDocument();
  });
Enter fullscreen mode Exit fullscreen mode

我们始终希望禁用此输入,因此我们还将为此添加一个测试:

  it("renders an input disabled", () => {
    render(<Calculator />);
    expect(screen.getByPlaceholderText("calculate")).toBeDisabled();
  });
Enter fullscreen mode Exit fullscreen mode

实现输入:

+ 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;
Enter fullscreen mode Exit fullscreen mode

带输入功能的计算器应用

让它显示用户的输入

测试:

  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");
  });
Enter fullscreen mode Exit fullscreen mode

通过测试:

               <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>
Enter fullscreen mode Exit fullscreen mode
         {calcOperators.map((c) => (
-          <button key={c}>{c.toString()}</button>
+          <button onClick={() => setValue(value.concat(c))} key={c}>
+            {c.toString()}
+          </button>
         ))}
Enter fullscreen mode Exit fullscreen mode

能计算吗?

好的,到目前为止我们只是编写了一些测试来检查我们的计算器是否显示正确的内容,但是让我们编写一些测试来实际计算一些东西:

  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");
  });
Enter fullscreen mode Exit fullscreen mode

请注意,在第二个测试中,我们还检查操作是否按正确的顺序执行:
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>
           );
Enter fullscreen mode Exit fullscreen mode

可以使用清除按钮

测试:

  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("");
  });
Enter fullscreen mode Exit fullscreen mode

简单的:

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) => (
Enter fullscreen mode Exit fullscreen mode

回到计算

好的,现在我们可能想测试更多计算函数的场景。所以我觉得直接在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);
  });
});
Enter fullscreen mode Exit fullscreen mode

我们的测试仍然通过了,除了除数为 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;
+  }
 };
Enter fullscreen mode Exit fullscreen mode

好的,针对一些额外的情况进行更多测试:

  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);
  });
Enter fullscreen mode Exit fullscreen mode

饮水机🌊

好吧,如果你坚持到了这里,恭喜你!🙌你正在学习如何以 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);
+    }
+
Enter fullscreen mode Exit fullscreen mode

摆脱 eval

还记得我们说过要把 eval 改成别的吗?没错,我们确实想避免使用它,因为我们的 linter 和常识都告诉我们不应该使用它。

幸运的是,有一个包可以满足我们的要求。将字符串作为表达式传递,然后安全地对其进行求值:
yarn add mathjs @types/mathjs

+import { evaluate } from "mathjs";
Enter fullscreen mode Exit fullscreen mode
-    // todo - refactor eval later
-    const result = eval(toEvaluate);
+    const result = evaluate(toEvaluate);
Enter fullscreen mode Exit fullscreen mode

什么……我们的测试还通过了?太棒了!

为应用程序设置样式

但是这个应用程序真的很丑......让我们修复它。

首先,让我们声明一些变量index.css

:root {
  --theme-color-dark-10: #006ba1;
  --theme-color-dark-20: #005a87;
  --theme-color-background: #fed800;
}
Enter fullscreen mode Exit fullscreen mode

在正文中,我们只需添加背景颜色,其余样式保持不变:

 body {
+  background-color: var(--theme-color-background);
Enter fullscreen mode Exit fullscreen mode

我们需要添加一些结构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;
Enter fullscreen mode Exit fullscreen mode

我们还将添加一个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;
}
Enter fullscreen mode Exit fullscreen mode

看起来好多了。
样式计算器

结论

我想就此打住 - 不过,该应用程序仍然存在一些错误和可以修复的问题。

如果您愿意,请以 TDD 风格修复它们🔥。

这是本次编码练习的repo

鏂囩珷鏉ユ簮锛�https://dev.to/alexandrudanpop/creating-a-calculator-app-with-react-and-tdd-277
PREV
何时选择 Gatsby、Next.Js 或 Create React App
NEXT
开发者免费 - 软件列表(SaaS、PaaS、IaaS 等)目录