ReactJS/Information

[React.js] React + Typescript 테스트 코드 작성하기

y0ngha 2022. 1. 4. 00:48

[ 같이 시작하기 : https://github.com/y0ngha/react-calculator/tree/Initial_start ]

테스트 코드 작성 전 참고 문서

TDD를 이용해 코드를 작성할 때에는 항상 지켜야하는 순서가 있다.

"요구사항 분석/파악" → "테스트 코드 작성" → "라이브 앱 작성"

 

위 순서에 맞게 글을 진행해보도록 하겠다.

테스트 가동

# NPM
npm test

# Yarn
yarn test

요구사항 분석/파악

위 링크 되어있는 깃허브를 들어가보면 알겠지만, 이번에 만들 앱은 "계산기"다.

계산기의 요구사항을 분석하고, 파악해보자.

  1. 사칙연산이 가능해야함.
  2. 숫자는 0~9까지 있음.
  3. 'CLEAR' 버튼을 누르게 될 경우 현재까지 계산한 식을 모두 지움.
  4. 숫자를 누른 후 사칙연산 버튼을 누르게 되거나 'ENTER' 버튼을 누를 경우 계산된 식을 TextBox에 표현.
    ( '1' 누르고, '+' 누르면 TextBox에는 '1'로 표시.. 이 후에 '2' 누르고 '+' 누르면 TextBox에는 '3'으로 표시)
  5. 'DEL' 버튼을 누르면 마지막으로 입력한 숫자를 삭제함.(마지막으로 입력한 숫자가 삭제되고 입력된 숫자가 아예 없을 경우에는 TextBox에 '0'으로 표시'

디자인(CSS)은 무시하고, 요구사항에 맞게 테스트코드를 작성하고 실제 앱을 작성해보도록 하자.

테스트 코드 작성

먼저 요구사항에 따라 사칙연산이 가능하게 해야하니, 사칙연산을 수행하는 서비스를 만들어주자.

 

/src/services/CalculatorLogic.test.ts

describe("Calculator Logic Test", () => {
    it("calculated 'add' Test", () => {
        expect(1+1).toBe(2)
    })

    it("calculated 'minus' Test", () => {
        expect(1-1).toBe(0)
    })

    it("calculated 'divide' Test", () => {
        expect(4/2).toBe(2)
    })

    it("calculated 'multiplication' Test", () => {
        expect(2*2).toBe(4)
    })
})

위 코드는 당연히 성공하게 된다.

원래 TDD의 사이클을 보면 RED를 먼저 경험해야하는데, 내가 부족해서인지 위처럼 간단한 코드에서 RED를 보는 방법을 모르겠다.

 

테스트코드를 작성했으니, 실제 로직을 구현하고 이를 테스트 로직에 적용시켜보도록 하자.

 

/src/services/CalculatorLogic.ts

export function add(targetNumber: number, calculatedNumber: number): number {
    return targetNumber + calculatedNumber
}

export function minus(targetNumber: number, calculatedNumber: number): number {
    return targetNumber - calculatedNumber
}

export function divide(targetNumber: number, calculatedNumber: number): number {
    return targetNumber / calculatedNumber
}

export function multiplication(targetNumber: number, calculatedNumber: number): number {
    return targetNumber * calculatedNumber
}

 

이 함수를 실제로 테스트코드에 적용해보자.

 

/src/services/CalculatorLogic.test.ts

import { add, minus, divide, multiplication} from "./CalculatorLogic";

describe("Calculator Logic Test", () => {
    it("calculated 'add' Test", () => {
        expect(add(1, 1)).toBe(2)
    })

    it("calculated 'minus' Test", () => {
        expect(minus(1, 1)).toBe(0)
    })

    it("calculated 'divide' Test", () => {
        expect(divide(4, 2)).toBe(2)
    })

    it("calculated 'multiplication' Test", () => {
        expect(multiplication(2, 2)).toBe(4)
    })
})

위 테스트코드가 PASS 되면 우리는 정상적으로 로직을 구현했음을 검증할 수 있다.

 

그럼 이제 실제 UI를 구현해보도록 하자.

앱에 있는 코드를 지우고, 따로 만든 컴포넌트를 불러와 렌더링 시킬 것인데 숫자와 텍스트, 버튼을 갖고 있는 Container를 만들어주도록 하자.

 

/src/components/container/CalculatorContainer.tsx

function CalculatorContainer() {
    return (
        <></>
    )
}

export default CalculatorContainer

위처럼 모양새만 잡아주고 테스트코드를 먼저 구현한 후 그에 맞게 UI를 구현할 것이다.

 

/src/components/container/CalculatorContainer.test.tsx

import React from "react"
import {render} from "@testing-library/react";
import CalculatorContainer from "./CalculatorContainer";

describe("CalculatorContainer Component Rendering", () => {
    const numbers: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
    const operations: string[] = ["+", "―", "÷", "×"]
    const specials: string[] = ["CLEAR", "ENTER", "DEL"]
    describe("ElementNodes rendering Check", () => {
        it("number Button Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            for(const number of numbers) {
                expect(wrapper.queryByText(number, {selector: "button"})).toBeTruthy()
            }
        })

        it("operation Button Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            for(const operation of operations) {
                expect(wrapper.queryByText(operation, {selector: "button"})).toBeTruthy()
            }
        })

        it("special Button Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            for(const special of specials) {
                expect(wrapper.queryByText(special, {selector: "button"})).toBeTruthy()
            }
        })

        it("result text Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            const input = wrapper.getByRole("resultText")
            expect(input).toHaveValue("0")
        })
    })
})

요구사항 조건에 맞게 컴포넌트의 Element들이 정상적으로 렌더링이 되고 있는지 테스트를 하는 케이스를 작성한 것이다.

위 코드를 보고 이제 실제 라이브 앱을 작성하러 가자.

 

/src/components/container/CalculatorContainer.tsx

function CalculatorContainer() {
    const numbers: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
    const operations: string[] = ["+", "―", "÷", "×"]
    const specials: string[] = ["CLEAR", "ENTER", "DEL"]
    return (
        <div className="container">
            <div className="result-wrapper">
                <input type="text" role="resultText" value={"0"} onChange={() => {}}/>
            </div>
            <div className="number-wrapper">
                {
                    numbers.map((number: number, index: number) => {
                        return (
                            <button key={`${number}-${index}`}>{number}</button>
                        )
                    })
                }
            </div>
            <div className="operation-wrapper">
                {
                    operations.map((operation: string, index: number) => {
                        return (
                            <button key={`${operation}-${index}`}>{operation}</button>
                        )
                    })
                }
            </div>
            <div className="special-wrapper">
                {
                    specials.map((special: string, index: number) => {
                        return (
                            <button key={`${special}-${index}`}>{special}</button>
                        )
                    })
                }
            </div>
        </div>
    )
}

export default CalculatorContainer

일단 당장 테스트를 통과시키기 위해 다른 컴포넌트 없이 직접 Container에 UI를 구현해줬다.

이를 이제 공통 컴포넌트로 뺄 수 있는 것은 빼주도록 하자.

 

버튼들은 모두 공통 컴포넌트를 하나 생성해 빼주도록 할 것인데, 이 또한 역시 테두리만 잡아놓고 테스트 코드에 구현 후 이를 라이브 앱에 구현해주도록 하자.

 

/src/components/presentational/Button.tsx

import React from "react"

export interface IButtonProps {
    displayText: string,
    onClickEvent: () => void,
    className?: string,
}

function Button({ displayText, onClickEvent, className }: IButtonProps ) {
    return (
        <></>
    )
}

export default Button

표시해줄 텍스트와, 버튼을 눌렀을 때의 이벤트 그리고 혹시 모를 CSS 작업을 위해 className까지 Props로 받기로 했다.

여기서 className은 안줄수도 있기 때문에 Nullable처리를 했다.

 

그럼 테스트 코드를 작성하고, 라이브 코드를 작성하자.

 

/src/components/presentational/Button.test.tsx

import React from "react"
import {render} from "@testing-library/react";
import Button from "./Button";
import userEvent from "@testing-library/user-event";

describe("Button Components Rendering", () => {
    const onClickEvent = jest.fn()
    it("Props Rendering Check", () => {
        const wrapper = render(<Button displayText={"+"} onClickEvent={onClickEvent}/>)
        const button = wrapper.queryByText("+")
        expect(button).toBeTruthy()

        if(button) {
            userEvent.click(button)
            expect(onClickEvent).toBeCalled()
        }
    })
})

Props로 받은 텍스트를 잘 표시하고 있는지, 잘 표시하고 있다면 버튼을 눌렀을 때 이벤트가 잘 호출되고 있는지에 대한 테스트 코드다.

이를 바탕으로 라이브 코드를 작성해주도록 하자.

 

/src/components/presentational/Button.tsx

import React from "react"

export interface IButtonProps {
    displayText: string,
    onClickEvent: () => void,
    className?: string,
}

function Button({ displayText, onClickEvent, className }: IButtonProps ) {
    return (
        <button className={className ?? "button-component"} onClick={onClickEvent}>{displayText}</button>
    )
}

export default Button

테스트코드가 통과되려면 위와 같은 코드가 나와야 한다.

 

이제 이 버튼 컴포넌트를 Container에 적용했을 때 오류가 없는지 확인하면 된다.

 

/src/components/container/CalculatorContainer.tsx

import Button from "../presentational/Button";

function CalculatorContainer() {
    const numbers: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
    const operations: string[] = ["+", "―", "÷", "×"]
    const specials: string[] = ["CLEAR", "ENTER", "DEL"]
    return (
        <div className="container">
            <div className="result-wrapper">
                <input type="text" role="resultText" value={"0"} onChange={() => {}}/>
            </div>
            <div className="number-wrapper">
                {
                    numbers.map((number: number, index: number) => {
                        return (
                            <Button key={`${number}-${index}`} displayText={number.toString()} onClickEvent={() => {}} />
                        )
                    })
                }
            </div>
            <div className="operation-wrapper">
                {
                    operations.map((operation: string, index: number) => {
                        return (
                            <Button key={`${operation}-${index}`} displayText={operation} onClickEvent={() => {}} />
                        )
                    })
                }
            </div>
            <div className="special-wrapper">
                {
                    specials.map((special: string, index: number) => {
                        return (
                            <Button key={`${special}-${index}`} displayText={special} onClickEvent={() => {}} />
                        )
                    })
                }
            </div>
        </div>
    )
}

export default CalculatorContainer

정상적인 작동을 하고 있음을 검증받았다.

 

이제 이벤트를 구현하고, 이 이벤트에 대한 테스트 코드 또한 작성하면 된다.

먼저 숫자를 눌렀을 때 result에 정상적으로 업데이트가 되고 있는지를 확인하는 코드를 작성해보자.

 

/src/components/container/CalculatorContainer.test.tsx

import React from "react"
import {render} from "@testing-library/react";
import CalculatorContainer from "./CalculatorContainer";
import userEvent from "@testing-library/user-event";

describe("CalculatorContainer Component Rendering", () => {
    const numbers: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
    const operations: string[] = ["+", "―", "÷", "×"]
    const specials: string[] = ["CLEAR", "ENTER", "DEL"]
    describe("ElementNodes rendering Check", () => {
        it("number Button Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)

            for(const number of numbers) {
                const button = wrapper.queryByText(number, {selector: "button"})
                expect(button).toBeTruthy()
            }
        })

        it("operation Button Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            for(const operation of operations) {
                expect(wrapper.queryByText(operation, {selector: "button"})).toBeTruthy()
            }
        })

        it("special Button Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            for(const special of specials) {
                expect(wrapper.queryByText(special, {selector: "button"})).toBeTruthy()
            }
        })

        it("result text Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            const input = wrapper.getByRole("resultText")
            expect(input).toHaveValue("0")
        })
    })

    describe("Button Action Test", () => {
        it("number Button Action Check", () => {
            let currentInNumber = ""
            const wrapper = render(<CalculatorContainer/>)
            const input = wrapper.getByRole("resultText")

            for(const number of numbers) {
                const button = wrapper.getByText(number, {selector: "button"})
                userEvent.click(button)
                expect(input).toHaveValue(currentInNumber.concat(number.toString()))
                currentInNumber += number.toString()
            }
        })
    })
})

button을 클릭하고, 그 값이 input에 잘 업데이트가 되고 있는지 확인하는 코드다.

이제 이것을 통과시켜보도록 하자.

 

/src/components/container/CalculatorContainer.tsx

import Button from "../presentational/Button";
import {useState} from "react";

function CalculatorContainer() {
    const numbers: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
    const operations: string[] = ["+", "―", "÷", "×"]
    const specials: string[] = ["CLEAR", "ENTER", "DEL"]

    const [result, setResult] = useState<number>(0)
    const [currentInNumber, setCurrentInNumber] = useState<number>(0)

    const [resultMode, setResultMode] = useState<boolean>(true)
    const [currentInMode, setCurrentInMode] = useState<boolean>(false)

    const fetchNumberButtonClick = (fetchNumber: number) => {
        setCurrentInNumber(Number(currentInNumber.toString().concat(fetchNumber.toString())))
        if(!currentInMode) {
            setCurrentInMode(true)
            setResultMode(false)
        }
    }

    return (
        <div className="container">
            <div className="result-wrapper">
                <input type="text" role="resultText" value={currentInMode ? currentInNumber : result} onChange={() => {}}/>
            </div>
            <div className="number-wrapper">
                {
                    numbers.map((number: number, index: number) => {
                        return (
                            <Button key={`${number}-${index}`} displayText={number.toString()} onClickEvent={() => {
                                fetchNumberButtonClick(number);
                            }} />
                        )
                    })
                }
            </div>
            <div className="operation-wrapper">
                {
                    operations.map((operation: string, index: number) => {
                        return (
                            <Button key={`${operation}-${index}`} displayText={operation} onClickEvent={() => {}} />
                        )
                    })
                }
            </div>
            <div className="special-wrapper">
                {
                    specials.map((special: string, index: number) => {
                        return (
                            <Button key={`${special}-${index}`} displayText={special} onClickEvent={() => {}} />
                        )
                    })
                }
            </div>
        </div>
    )
}

export default CalculatorContainer

위와 같이 작성하면 일단 테스트 코드는 PASS 처리 된다.

fetchNumberButtonClick(); 함수나 state 사용된게 이것저것 마음에 안들기는 하지만, 일단 통과시키기 위해 저렇게 작성했다.

이 코드를 이쁘게 리팩토링 하면 된다.

(당장 여기서는 진행하기에는 글이 너무 길어져 진행하지 않지만, Reducer Hook을 사용하면 조금 더 이쁘게 바꿀 수 있을 것 같다.)

 

Opertaion Button을 눌렀을 때의 이벤트도 테스트 코드를 작성 후 라이브 앱에 작성해보도록 하자.

 

Operation Button을 구현하던중 라이브 앱의 코드를 수정할 일이 생겼다.

위의 코드로도 작동하게 할 순 있지만, 너무 번거로운 작업이 될 것 같아 아래와 같이 수정했다.

 

/src/components/container/CalculatorContainer.tsx

function CalculatorContainer() {
    const numbers: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
    const operations: string[] = ["+", "―", "÷", "×"]
    const specials: string[] = ["CLEAR", "ENTER", "DEL"]

    const [showDisplayNumber, setShowDisplayNumber] = useState<number>(0)

    const [result, setResult] = useState<number>(0)
    const [currentInNumber, setCurrentInNumber] = useState<number>(0)

    const fetchNumberButtonClick = (fetchNumber: number) => {
        const concatNumber = Number(currentInNumber.toString().concat(fetchNumber.toString()))
        setCurrentInNumber(concatNumber)

        setShowDisplayNumber(concatNumber)
    }

    return (
        <div className="container">
            <div className="result-wrapper">
                <input type="text" role="resultText" value={showDisplayNumber} onChange={() => {}} readOnly/>
            </div>
            <div className="number-wrapper">
                {
                    numbers.map((number: number, index: number) => {
                        return (
                            <Button key={`${number}-${index}`} displayText={number.toString()} onClickEvent={() => {
                                fetchNumberButtonClick(number);
                            }} />
                        )
                    })
                }
            </div>
            <div className="operation-wrapper">
                {
                    operations.map((operation: string, index: number) => {
                        return (
                            <Button key={`${operation}-${index}`} displayText={operation} onClickEvent={() => {}} />
                        )
                    })
                }
            </div>
            <div className="special-wrapper">
                {
                    specials.map((special: string, index: number) => {
                        return (
                            <Button key={`${special}-${index}`} displayText={special} onClickEvent={() => {}} />
                        )
                    })
                }
            </div>
        </div>
    )
}

export default CalculatorContainer

state가 너무 비정상적으로 선언이 되어있기도 했고, 정상적인 앱 구동을 위해서는 위와 같이 리팩토링을 해야 했다.

이렇게 과감한 리팩토링이 가능한 이유 또한 TDD가 작동하며 내가 설계했던 요구사항대로는 동작하고 있음을 알려주고 있기 때문에 가능한 것이다.

 

Operation Button의 정상동작 여부를 테스트하는 코드는 아래와 같이 구성했다.

 

/src/components/container/CalculatorContainer.test.tsx

import React from "react"
import {render} from "@testing-library/react";
import CalculatorContainer from "./CalculatorContainer";
import userEvent from "@testing-library/user-event";

describe("CalculatorContainer Component Rendering", () => {
    const numbers: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
    const operations: string[] = ["+", "―", "÷", "×"]
    const specials: string[] = ["CLEAR", "ENTER", "DEL"]
    describe("ElementNodes rendering Check", () => {
        it("number Button Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)

            for (const number of numbers) {
                const button = wrapper.queryByText(number, {selector: "button"})
                expect(button).toBeTruthy()
            }
        })

        it("operation Button Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            for (const operation of operations) {
                expect(wrapper.queryByText(operation, {selector: "button"})).toBeTruthy()
            }
        })

        it("special Button Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            for (const special of specials) {
                expect(wrapper.queryByText(special, {selector: "button"})).toBeTruthy()
            }
        })

        it("result text Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            const input = wrapper.getByRole("resultText")
            expect(input).toHaveValue("0")
        })
    })

    describe("Button Action Test", () => {
        it("number Button Action Check", () => {
            let currentInNumber = ""
            const wrapper = render(<CalculatorContainer/>)
            const input = wrapper.getByRole("resultText")

            for (const number of numbers) {
                const button = wrapper.getByText(number, {selector: "button"})
                userEvent.click(button)
                expect(input).toHaveValue(currentInNumber.concat(number.toString()))
                currentInNumber += number.toString()
            }
        })

        it("operation Button Action Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            const oneButton = wrapper.getByText(1, {selector: "button"})
            const twoButton = wrapper.getByText(2, {selector: "button"})
            const plusButton = wrapper.getByText("+", {selector: "button"}) // PLUS Button
            const input = wrapper.getByRole("resultText")

            expect(input).toHaveValue("0")

            userEvent.click(oneButton)
            expect(input).toHaveValue("1")

            userEvent.click(plusButton)


            expect(input).toHaveValue("0")

            userEvent.click(oneButton)
            userEvent.click(plusButton)

            expect(input).toHaveValue("2")

            userEvent.click(twoButton)
            userEvent.click(plusButton)

            expect(input).toHaveValue("4")
        })
    })
})

더하기 버튼만으로 테스트를 진행했을 때 문제가 없다면, 통과되는 코드다.

요구사항에 맞게 초기 값이 없을 때는 "0"을 표시하고 있고, 1을 누르게 되면 1이 표시되고, 더하기 버튼을 누르면 "0"이 표시되고, 이 후에 숫자, 오퍼레이션 버튼을 누르게 되면 결과가 표시되는 것이다.

이 후에 또 2를 누르고 plusButton을 누르게 되면 1+1+2 식이 성립되어 "4"가 표시되는 것이다.

 

위 코드를 문제 없게 PASS 시키면 우리가 원하는 결과를 볼 수 있다.

 

/src/components/container/CalculatorContainer.tsx

import Button from "../presentational/Button";
import {useState} from "react";

function CalculatorContainer() {
    const numbers: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
    const operations: string[] = ["+", "―", "÷", "×"]
    const specials: string[] = ["CLEAR", "ENTER", "DEL"]

    const [showDisplayNumber, setShowDisplayNumber] = useState<number>(0)

    const [result, setResult] = useState<number>(0)
    const [currentInNumber, setCurrentInNumber] = useState<number>(0)

    const fetchNumberButtonClick = (fetchNumber: number) => {
        const concatNumber = Number(currentInNumber.toString().concat(fetchNumber.toString()))
        setCurrentInNumber(concatNumber)

        setShowDisplayNumber(concatNumber)
    }

    const fetchOperationButtonClick = (fetchOperation: string) => {
        if (result === 0 && currentInNumber !== 0) {
            //초기 상태에서 눌렀을 경우
            setResult(currentInNumber)
            setCurrentInNumber(0)

            setShowDisplayNumber(0)
        } else if (result !== 0 && currentInNumber !== 0) {
            let newResult = 0
            switch (fetchOperation) {
                case "+":
                    newResult = result + currentInNumber
                    break;
                case "―":
                    newResult = result - currentInNumber
                    break;
                case "÷":
                    newResult = result / currentInNumber
                    break;
                case "×":
                    newResult = result * currentInNumber
                    break;
            }
            setCurrentInNumber(0)
            setResult(newResult)
            setShowDisplayNumber(newResult)
        }
    }

    return (
        <div className="container">
            <div className="result-wrapper">
                <input type="text" role="resultText" value={showDisplayNumber} onChange={() => {
                }} readOnly/>
            </div>
            <div className="number-wrapper">
                {
                    numbers.map((number: number, index: number) => {
                        return (
                            <Button key={`${number}-${index}`} displayText={number.toString()} onClickEvent={() => {
                                fetchNumberButtonClick(number);
                            }}/>
                        )
                    })
                }
            </div>
            <div className="operation-wrapper">
                {
                    operations.map((operation: string, index: number) => {
                        return (
                            <Button key={`${operation}-${index}`} displayText={operation} onClickEvent={() => {
                                fetchOperationButtonClick(operation)
                            }}/>
                        )
                    })
                }
            </div>
            <div className="special-wrapper">
                {
                    specials.map((special: string, index: number) => {
                        return (
                            <Button key={`${special}-${index}`} displayText={special} onClickEvent={() => {
                            }}/>
                        )
                    })
                }
            </div>
        </div>
    )
}

export default CalculatorContainer

이제 마지막으로 Special Button에 대한 테스트 코드를 작성하고, 기능을 구현해보자.

 

/src/components/container/CalculatorContainer.test.tsx

import React from "react"
import {render} from "@testing-library/react";
import CalculatorContainer from "./CalculatorContainer";
import userEvent from "@testing-library/user-event";

describe("CalculatorContainer Component Rendering", () => {
    const numbers: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
    const operations: string[] = ["+", "―", "÷", "×"]
    const specials: string[] = ["CLEAR", "ENTER", "DEL"]
    describe("ElementNodes rendering Check", () => {
        it("number Button Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)

            for (const number of numbers) {
                const button = wrapper.queryByText(number, {selector: "button"})
                expect(button).toBeTruthy()
            }
        })

        it("operation Button Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            for (const operation of operations) {
                expect(wrapper.queryByText(operation, {selector: "button"})).toBeTruthy()
            }
        })

        it("special Button Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            for (const special of specials) {
                expect(wrapper.queryByText(special, {selector: "button"})).toBeTruthy()
            }
        })

        it("result text Rendering Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            const input = wrapper.getByRole("resultText")
            expect(input).toHaveValue("0")
        })
    })

    describe("Button Action Test", () => {
        it("number Button Action Check", () => {
            let currentInNumber = ""
            const wrapper = render(<CalculatorContainer/>)
            const input = wrapper.getByRole("resultText")

            for (const number of numbers) {
                const button = wrapper.getByText(number, {selector: "button"})
                userEvent.click(button)
                expect(input).toHaveValue(currentInNumber.concat(number.toString()))
                currentInNumber += number.toString()
            }
        })

        it("operation Button Action Check", () => {
            const wrapper = render(<CalculatorContainer/>)
            const oneButton = wrapper.getByText(1, {selector: "button"})
            const twoButton = wrapper.getByText(2, {selector: "button"})
            const plusButton = wrapper.getByText("+", {selector: "button"}) // PLUS Button
            const input = wrapper.getByRole("resultText")

            expect(input).toHaveValue("0")

            userEvent.click(oneButton)
            expect(input).toHaveValue("1")

            userEvent.click(plusButton)


            expect(input).toHaveValue("0")

            userEvent.click(oneButton)
            userEvent.click(plusButton)

            expect(input).toHaveValue("2")

            userEvent.click(twoButton)
            userEvent.click(plusButton)

            expect(input).toHaveValue("4")
        })

        describe("special Button Action Check", () => {

            it("CLEAR Button Action Check", () => {
                const wrapper = render(<CalculatorContainer/>)
                const oneButton = wrapper.getByText(1, {selector: "button"})
                const clearButton = wrapper.getByText("CLEAR", {selector: "button"})
                const input = wrapper.getByRole("resultText")

                expect(input).toHaveValue("0")

                userEvent.click(oneButton)

                expect(input).toHaveValue("1")

                userEvent.click(clearButton)

                expect(input).toHaveValue("0")
            })

            it("DEL Button Action Check", () => {
                const wrapper = render(<CalculatorContainer/>)
                const oneButton = wrapper.getByText(1, {selector: "button"})
                const delButton = wrapper.getByText("DEL", {selector: "button"})
                const input = wrapper.getByRole("resultText")

                expect(input).toHaveValue("0")

                userEvent.click(oneButton)
                userEvent.click(oneButton)

                expect(input).toHaveValue("11")

                userEvent.click(delButton)
                expect(input).toHaveValue("1")

                userEvent.click(delButton)
                expect(input).toHaveValue("0")
            })

            it("ENTER Button Action Check", () => {
                const wrapper = render(<CalculatorContainer/>)
                const oneButton = wrapper.getByText(1, {selector: "button"})
                const enterButton = wrapper.getByText("ENTER", {selector: "button"})
                const plusButton = wrapper.getByText("+", {selector: "button"})
                const input = wrapper.getByRole("resultText")

                expect(input).toHaveValue("0")

                userEvent.click(oneButton)
                userEvent.click(plusButton)
                userEvent.click(oneButton)
                userEvent.click(enterButton)

                expect(input).toHaveValue("2")

            })
        })
    })
})

마찬가지로 요구사항의 조건에 맞게끔 테스트 코드를 작성해줬다.

이제 라이브 앱에만 구현하고 PASS 시키면 이 앱이 마무리가 된다.

 

/src/components/container/CalculatorContainer.tsx

import Button from "../presentational/Button";
import {useState} from "react";
import {add, divide, minus, multiplication} from "../../services/CalculatorLogic";

function CalculatorContainer() {
    const numbers: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
    const operations: string[] = ["+", "―", "÷", "×"]
    const specials: string[] = ["CLEAR", "ENTER", "DEL"]

    const [showDisplayNumber, setShowDisplayNumber] = useState<number>(0)

    const [result, setResult] = useState<number>(0)
    const [currentInNumber, setCurrentInNumber] = useState<number>(0)
    const [lastKeyInOperation, setLastKeyInOperation] = useState<string>("")

    const fetchNumberButtonClick = (fetchNumber: number) => {
        const concatNumber = Number(currentInNumber.toString().concat(fetchNumber.toString()))
        setCurrentInNumber(concatNumber)

        setShowDisplayNumber(concatNumber)
    }

    const getResult = (fetchOperation: string): number => {
        switch (fetchOperation) {
            case "+":
                return add(result, currentInNumber)
            case "―":
                return minus(result, currentInNumber)
            case "÷":
                return divide(result, currentInNumber)
            case "×":
                return multiplication(result, currentInNumber)
            default:
                return 0
        }
    }

    const fetchResult = (fetchOperation: string) => {
        const newResult = getResult(fetchOperation)
        setCurrentInNumber(0)
        setResult(newResult)
        setShowDisplayNumber(newResult)

    }
    const fetchOperationButtonClick = (fetchOperation: string) => {
        if (result === 0 && currentInNumber !== 0) {
            //초기 상태에서 눌렀을 경우
            setResult(currentInNumber)
            setCurrentInNumber(0)

            setShowDisplayNumber(0)
        } else if (result !== 0 && currentInNumber !== 0) {
            fetchResult(fetchOperation)
        }

        setLastKeyInOperation(fetchOperation)
    }

    const fetchSpecialButtonClick = (fetchSpecial: string) => {
        switch (fetchSpecial) {
            case "CLEAR":
                setCurrentInNumber(0)
                setResult(0)
                setShowDisplayNumber(0)
                break;
            case "ENTER":
                if (result !== 0 && currentInNumber !== 0) {
                    fetchResult(lastKeyInOperation)
                }

                break;
            case "DEL":
                if(currentInNumber !== 0) {
                    const temp = showDisplayNumber.toString().slice(0, -1)
                    const newShowDisplayNumber = Number(temp === "" ? 0 : Number(temp));
                    setCurrentInNumber(newShowDisplayNumber)
                    setShowDisplayNumber(newShowDisplayNumber)
                }
                break;
        }
    }

    return (
        <div className="container">
            <div className="result-wrapper">
                <input type="text" role="resultText" value={showDisplayNumber} onChange={() => {
                }} readOnly/>
            </div>
            <div className="number-wrapper">
                {
                    numbers.map((number: number, index: number) => {
                        return (
                            <Button key={`${number}-${index}`} displayText={number.toString()} onClickEvent={() => {
                                fetchNumberButtonClick(number);
                            }}/>
                        )
                    })
                }
            </div>
            <div className="operation-wrapper">
                {
                    operations.map((operation: string, index: number) => {
                        return (
                            <Button key={`${operation}-${index}`} displayText={operation} onClickEvent={() => {
                                fetchOperationButtonClick(operation)
                            }}/>
                        )
                    })
                }
            </div>
            <div className="special-wrapper">
                {
                    specials.map((special: string, index: number) => {
                        return (
                            <Button key={`${special}-${index}`} displayText={special} onClickEvent={() => {
                                fetchSpecialButtonClick(special)
                            }}/>
                        )
                    })
                }
            </div>
        </div>
    )
}

export default CalculatorContainer

반복되는 코드도 몇개가 있어 간단하게만 리팩토링 해줬다.

실제로는 할게 더 많지만, 리팩토링을 다루는 글이 아닌 만큼 그냥 넘어가도록 하겠다.

 

 PASS  src/services/CalculatorLogic.test.ts
 PASS  src/components/presentational/Button.test.tsx
 PASS  src/components/container/CalculatorContainer.test.tsx

Test Suites: 3 passed, 3 total
Tests:       14 passed, 14 total
Snapshots:   0 total
Time:        2.7 s, estimated 3 s
Ran all test suites related to changed files.

Watch Usage: Press w to show more.

ALL PASS

 

 

이제 앱을 구경하러 가보자.

(물론, 코드를 작성하면서 아예 안봤다는 것은 거짓말이고 한 2~3번 본 것 같다.)

 

결과

디자인은 형편없지만, 기능은 잘 작동한다.

 

마치며

테스트 코드를 기반으로 작성하는 앱은 안정성도 더 뛰어나고, 내가 미처 캐치하지 못했던 부분들 또한 알려주고 있다.

(실제로 저 간단한 앱을 만들면서 몇번 생각하지도 못했던 부분을 TDD가 캐치해줬다.)

테스트 코드를 먼저 작성함으로써 내가 구현해야할 기능에 대해 안정적으로, 빠르게 작성 가능한 것 같다.

이 후에는 Redux, Axios 등 외부 패키지를 이용한 테스트코드도 작성해볼 것이다.

(현재 공부는 어느정도 했는데, 먼저 간단한 예제를 만들며 감을 잡고 있다..)

테스트 코드를 작성하는건 실제 라이브 앱에 비해 정말 반복적인, 귀찮은 작업이다.

하지만 이 테스트 코드를 작성함으로써 나의 앱에 안정성이 늘어난다면 할만한 것 같다.

 

TDD 원칙중 반복적인 코드는 함수나 fixture로 뺐어야했는데 너무 귀찮아서 안했다.

(실제로는 반복적인 코드를 전부 빼줘야한다. - 관심사의 분리)


참고로 위에 작성한 코드는 Enum으로 관리하거나 따로 다 뺏어야했는데, 그러지 않은 것이다.

저 코드가 정답이 아니고, 정말 반복적인 안좋은 코드가 많은 것이다.

(내가 썻지만 정말 대충 쓴것 같다...)

 

[ 완료 코드 : https://github.com/y0ngha/react-calculator/tree/final_code ]