[Canvas-불꽃놀이-33] index.js 통합 테스트

이전 글 : [Canvas-불꽃놀이-32] Canvas 클래스 단위 테스트 (애니메이션)

 

[Canvas-불꽃놀이-32] Canvas 클래스 단위 테스트 (애니메이션)

이전 글 : [Canvas-불꽃놀이-31] Canvas 클래스 테스트 (파티클 업데이트) [Canvas-불꽃놀이-31] Canvas 클래스 테스트 (파티클 업데이트)이전 글 : [개발/Canvas] - [Canvas-불꽃놀이-30] Canvas 클래스 테스트 (파

jinsk-joy.tistory.com

 

화면 전환 및 애니메이션 제어를 위한 index.js의 통합 테스트

 

index.js 통합 테스트

마지막으로 index.js의 통합 테스트를 진행하여 모든 테스트를 마무리하려 한다.

index.js 파일은 전체 프로젝트 초기화, 이벤트 리스너, DOM 요소, 화면 전환, 애니메이션 실행 등 다양한 기능이 서로 연결되어 있다. 

단위 테스트 만으론 이러한 기능이 제대로 동작하는지 확인이 어려우므로 통합테스트를 진행해야 한다.

통합 테스트를 통해 기능 간 상호작용과 전체척인 흐름의 안정성을 확인할 수 있다.

 

index.js 전체 코드

더보기
import Canvas from "@/js/Canvas.js";
import { LOCATION_HASH, SCREEN } from "@/js/constants.js";

const canvas = new Canvas();

const { HOME, FIREWORKS, FIREWORKS_HASH } = LOCATION_HASH;

const domElements = {
    body: document.body,
    home: document.getElementById(HOME),
    fireworks: document.getElementById(FIREWORKS),
    inputForm: document.getElementById("input_form"),
    userInput: document.getElementById("user_input"),
};

const isHashFireworks = () => location.hash == FIREWORKS_HASH;

/**
 * @param {string} screen home || fireworks
 */
const switchScreen = (screen) => {
    domElements.home.style.display = screen == HOME ? "flex" : "none";
    domElements.fireworks.style.display = screen == FIREWORKS ? "block" : "none";
};

/** 리스너 */
const handleLoad = () => {
    domElements.body.style.visibility = "visible";
    if (isHashFireworks()) {
        location.hash = "";
    }
};

let resizeTimeout;
const handleResize = () => {
    clearTimeout(resizeTimeout);

    resizeTimeout = setTimeout(() => {
        if (isHashFireworks()) {
            canvas.init();
            canvas.createTextDatas();
        }
    }, SCREEN.RESIZE_DELAY);
};

const handleSubmit = (e) => {
    e.preventDefault();

    const userInputValue = domElements.userInput.value.trim();
    if (userInputValue.length > 0 && userInputValue.length <= 10) {
        canvas.text = userInputValue;
        canvas.textLength = userInputValue.length;
        location.hash = FIREWORKS_HASH;
    } else {
        alert("글자를 입력해주세요. 1~10글자까지 입력 가능합니다.");
    }
};

const handleHashChange = () => {
    if (isHashFireworks()) {
        canvas.init();
        canvas.render();

        switchScreen(FIREWORKS);
    } else {
        cancelAnimationFrame(canvas.animationId);
        domElements.userInput.value = "";
        canvas.initCanvasVars();

        switchScreen(HOME);
    }
};

/** 이벤트 */
window.addEventListener("load", handleLoad);
window.addEventListener("resize", handleResize);
domElements.inputForm.addEventListener("submit", (e) => handleSubmit(e));
window.addEventListener("hashchange", handleHashChange);

switchScreen(isHashFireworks() ? FIREWORKS : HOME);

 

1. 테스트 환경 설정

테스트 전

  • index.js가 의존하는 DOM 구조의 필수 HTML 요소를 테스트 환경의 document.body에 추가한다. 
  • hash는 초기 상태를 명확하게 하기 위해 빈 문자열 "" 로 설정한다.
  • 통합 테스트를 위해 Index.js 를 실제로 로드하여 이벤트 리스너와 화면 조작 로직이 설정되도록 한다.
    index.js에는 다양한 이벤트 리스너와 전역 객체(window 등) 설정되어 있다. 이를 테스트 환경에 로드해야 이벤트와 로직이 연결된 상태에서 테스트 진행이 가능하며 로드하지 않으면 애플리케이션의 이벤트 리스너와 전역 상태 초기화 로직이 동작하지 않아 통합테스트가 불가능하다.
    index.js 내의 이벤트 리스너와 로직은 모듈이 완전히 로드된 다음에 정상 동작하므로 async/await을 사용하여 모듈 로드과 완료된 후 다음 코드가 실행될 수 있도록 해야 한다.
// index.test.js
// 테스트 전 index.js 파일을 모듈로 import한다.
beforeEach(async () => {
    // 테스트에 필요한 html 요소 추가
    document.body.innerHTML = `
        <div id="home">
            <form id="input_form" aria-label="입력 폼">
                <label for="user_input">글자를 입력해주세요</label>
                <input id="user_input" />
                <button id="submit_btn" type="submit">완료</button>
            </form>
        </div>
        <div id="fireworks">
            <canvas id="canvas"></canvas>
        </div>
    `;
    window.location.hash = ""; // 초기 hash 상태
    await import("@/index.js"); // index.js 로드
});

 

index.js를 로드하지 않을 경우 오류 화면 

테스트 후

  • 테스트가 종료된 후는 document.body의 내용을 비우고 import 한 Index.js 모듈도 리셋하여 다음 테스트에 영향이 가지 않도록 한다.
// index.test.js
// 테스트 후 
afterEach(() => {
    document.body.innerHTML = "";
    jest.resetModules();
});

 

2. load 이벤트

  • 페이지 로드가 완료되기 전 화면 밀림 현상을 방지하기 위해 초기 body의 visiblilty를 hidden으로 설정하고, 로드가 완료된 뒤 visible로 변경한다.
  • 로드 시 hash가 #fireworks라면 다시 ""로 설정하여 애니메이션 실행 오류 가능성을 방지한다.
// index.test.js
describe("load 이벤트 테스트", () => {
    beforeEach(() => {
        document.body.style.visibility = "hidden";
    });

    // 생략...
}

  • load 후 visibility와 hash별 이벤트 결과가 예상과 일치하는지 확인한다.
// index.test.js
test.each([
    { hash: "", notice: `hash가 ""에서 ""로 변경되지 않는다.` },
    { hash: FIREWORKS_HASH, notice: `hash가 ${FIREWORKS_HASH}에서 ""로 변경된다.` },
])("hash에 따른 load 테스트 | 로드 후 $notice", ({ hash }) => {
    // 로드전 hash 설정
    window.location.hash = hash;

    // 테스트 환경에서 load 이벤트를 생성하고 window에 이벤트를 등록하여 강제로 실행시킨다.
    const loadEvent = new Event("load");
    window.dispatchEvent(loadEvent);

    // 로드 후 결과가 예상값과 일치하는지 검증한다.
    expect(document.body.style.visibility).toBe("visible");
    expect(window.location.hash).toBe("");
});

 

3. resize 이벤트

  • resize 이벤트에서 사용되는 메서드의 호출을 감시하기 위해 스파이를 선언하고 등록한다.
  • setTimeout, clearTimeout을 사용하므로 fakeTimer을 사용한다.
  • 테스트 후 스파이 호출 기록을 초기화하고 타이머도 실제 타이머를 사용하도록 한다.
// index.test.js
// 테스트 전
beforeEach(() => {
    jest.useFakeTimers();
    spyInit = jest.spyOn(Canvas.prototype, "init");
    spyCreateTextDatas = jest.spyOn(Canvas.prototype, "createTextDatas");
    spyClearTimeout = jest.spyOn(global, "clearTimeout");
    spySetTimeout = jest.spyOn(global, "setTimeout");
});

// 테스트 후
afterEach(() => {
    jest.clearAllTimers();
    jest.clearAllMocks();
});

  • resize 이벤트가 진행될 때 hash가 #fireworks면 canvas를 초기화하고 텍스트 데이터를 다시 생성해야 화면에 맞는 애니메이션을 볼 수가 있다. 
  • 이를 검증하기 위해 resize 이벤트가 발생될 때 hash가 #fireworks면 init, createTextDatas 메서드가 호출되는지, hash가 ""이면 아무 호출도 일어나지 않는지 확인하여 불필요한 호출이 일어나지 않도록 방지한다.
// index.test.js
test.each([
    { hash: "", notice: `""일 때 아무것도 실행하지 않는다.` },
    { hash: FIREWORKS_HASH, notice: `${FIREWORKS_HASH}일 때 canvas 초기화와 텍스트 데이터를 새로 생성한다.` },
])("hash에 따른 resize 이벤트 | hash가 $notice", ({ hash }) => {
    window.location.hash = hash;

    // resize 이벤트 생성해서 등록해 이벤트를 발생시킨다.
    const resizeEvent = new Event("resize");
    window.dispatchEvent(resizeEvent);

    // clearTimeout과 setTimeout과 콜백함수가 실행됐는지 검증한다.
    expect(spyClearTimeout).toHaveBeenCalled();
    expect(spySetTimeout).toHaveBeenCalledWith(expect.any(Function), SCREEN.RESIZE_DELAY);

    // 타이머를 실행시킨다.
    jest.runAllTimers();

    // hash에 따른 메서드의 호출 여부를 검증한다.
    if (hash === FIREWORKS_HASH) {
        expect(spyInit).toHaveBeenCalled();
        expect(spyCreateTextDatas).toHaveBeenCalled();
    } else {
        expect(spyInit).not.toHaveBeenCalled();
        expect(spyCreateTextDatas).not.toHaveBeenCalled();
    }
});

 

4. submit 이벤트

  • hashchange 이벤트와 애니메이션 실행의 기준이 되는 submit 이벤트의 테스트 환경을 설정하도록 한다.
  • 이 부분은 입력과 버튼 클릭의 유저 인터렉션이 들어가는 부분이므로 DOM 요소를 다루기 편한 @testing-library/dom를 설치한다.
  • 이 라이브러리는 DOM 요소와 사용자 간의 상호작용 테스트를 간소화하며, html 요소를 가져올 때 getByRole, getByPlaceholderText 등의 직관적인 쿼리를 제공하여 명확하고 직관적인 테스트 코드 작성이 가능하다.
  • 테스트에선 getByLabelText, getByRole, fireEvent 기능을 사용한다.
    -. getByLabelText : 라벨과 연결된 입력 필드 선택 가능
    -. getByRole : 버튼, form 등 역할 기반 선택 가능
    -. fireEvent : 클릭, 입력 등 사용자 이벤트를 시뮬레이션하여 실제처럼 동작하게 만드는 기능
npm install @testing-library/dom --save-dev

  • 입력값이 없을 때 실행되는 alert 함수의 호출을 확인하기 위해 스파이로 등록하고 테스트 후 호출 기록을 초기화한다.
// index.test.js
describe("submit 이벤트 테스트", () => {
    const [enterKey, buttonClick] = ["엔터", "버튼"];
    let spyAlert;

    // alert 호출 기록 감시를 위한 스파이 등록
    beforeEach(() => {
        spyAlert = jest.spyOn(window, "alert").mockImplementation(() => {});
    });

    // 호출 기록 초기화
    afterEach(() => {
        spyAlert.mockClear();
    });

    // 생략
}

  • submit 이벤트는 form 태그를 사용했으므로 엔터키 또는 버튼 두 가지의 방법으로 이벤트를 발생시킬 수 있으며, 의도한 결과가 나오는지 검증하는 공통 함수를 만들어 사용하도록 한다.
// index.test.js
function callSubmitEvent({ type, value }) {
    // 라벨과 연결된 입력필드를 선택하여 input 이벤트를 발생시킨다.
    const userInput = screen.getByLabelText("글자를 입력해주세요");
    fireEvent.input(userInput, { target: { value } });

    // 엔터키 이벤트일 때 form 태그를 가져와 submit 이벤트를 발생시킨다.
    if (type === enterKey) fireEvent.submit(screen.getByRole("form"));
    
    // 버튼 클릭 이벤트일 때 버튼 태그를 가져와 click 이벤트를 발생시킨다.
    if (type === buttonClick) fireEvent.click(screen.getByRole("button", { name: "완료" }));
}

  • 입력값이 유효한 값인 경우(1~10글자)를 검증하도록 한다. (엔터키, 버튼 클릭)
// index.test.js
test.each([
    { type: enterKey, value: "테스트 중", notice: "엔터 키 입력" },
    { type: buttonClick, value: "1234567890", notice: "버튼 클릭" },
])("$notice으로 submit 이벤트 트리거", ({ type, value }) => {
    callSubmitEvent({ type, value });

    // 입력값이 유효한 값일 경우 hash는 변경되고 alert는 호출되지 않는다.
    expect(window.location.hash).toBe(FIREWORKS_HASH);
    expect(spyAlert).not.toHaveBeenCalled();
});

  • 입력값이 유효하지 않는 값일 경우 입력값이 없건, 11글자 이상)를 검증하도록 한다. (엔터키, 버튼 클릭)
// index.test.js
test.each([
    { type: enterKey, value: "", notice: "엔터 키 입력" },
    { type: buttonClick, value: "123456789011", notice: "버튼 클릭" },
])("1~10 글자가 아닐 때 $notice으로 submit 이벤트 트리거", ({ type, value }) => {
    callSubmitEvent({ type, value });

    // 입력값이 유효하지 않는 경우 hash는 변경되지 않으며 alert이 호출된다.
    expect(window.location.hash).toBe("");
    expect(spyAlert).toHaveBeenCalledWith(expect.any(String));
});

 

5. hashchange 이벤트

  • URL의 hash 값에 따라 화면 전환 및 애니메이션 상태를 제어하므로 변화를 유발하는 메서드의 호출을 감지하기 위해 스파이를 등록하고, 테스트 후에는 호출 기록을 초기화해 다음 테스트에 영향이 없도록 한다.
// index.test.js
describe("hashchange 이벤트 테스트", () => {
    let spyCanvasInit, spyCanvasRender, spyInitCanvasVars;
    let spyCancelAnimaionFrame;

    // 화면 전환시 발생하는 이벤트의 호출을 감시하기 위해 스파이로 등록
    beforeEach(() => {
        spyCanvasInit = jest.spyOn(Canvas.prototype, "init");
        spyCanvasRender = jest.spyOn(Canvas.prototype, "render");
        spyInitCanvasVars = jest.spyOn(Canvas.prototype, "initCanvasVars");
        spyCancelAnimaionFrame = jest.spyOn(global, "cancelAnimationFrame");

        // 애니메이션 실행을 위해 document.timeline을 mocking한다.
        defineDomObjectProperty({ domObj: document, property: "timeline", value: { currentTime: 0 } });
    });

    // 테스트 종료 후 스파이의 호출 기록을 초기화한다.
    afterEach(() => {
        jest.clearAllMocks();
    });

    // 생략
}

  • hashchange 이벤트를 실행시키는 공통 함수를 만든다.
// index.test.js
function callHashchangeEvent(hash = "") {
    window.location.hash = hash; // 파라미터로 전달받은 hash 적용

    // hashchange 이벤트를 생성해 발생시킨다.
    const hashChangeEvent = new Event("hashchange");
    window.dispatchEvent(hashChangeEvent);
}

  • hashchange에 따른 화면 스타일 변화를 검증하는 공통 함수를 만든다.
// index.test.js
// 각 페이지의 스타일을 파라미터로 받는다.
function expectedPageStyle({ home, fireworks }) {
    expect(document.getElementById(HOME).style.display).toBe(home); // flex || none
    expect(document.getElementById(FIREWORKS).style.display).toBe(fireworks); // block || none
}

  • hash가 "" 에서 #fireworks로 변경될 때 애니메이션이 실행되고 fireworks 페이지로 화면 전환이 이루어지는지 검증한다.
// index.test.js
test(`hash가 ""에서 ${FIREWORKS_HASH}으로 변경될 때`, () => {
    callHashchangeEvent(FIREWORKS_HASH);

    // 애니메이션을 실행해야하니 캔버스관련 메서드가 호출, 화면 변화가 의도대로 실행됬는지 검증한다.
    expect(spyCanvasInit).toHaveBeenCalledTimes(1);
    expect(spyCanvasRender).toHaveBeenCalledTimes(1);
    expectedPageStyle({ home: "none", fireworks: "block" });
});

  • hash가 #fireworks에서 "" 으로 변경될 때 애니메이션이 취소되고 입력내용이 초기화되며 home 페이지로 화면 전환이 이루어지는지 검증한다.
// index.test.js
test(`hsah가 ${FIREWORKS_HASH}에서 ""으로 변경될 때`, () => {
    callHashchangeEvent();

    // ""로 hashchangerk 발생되면 애니메이션을 취소시키고 이전 상태로 초기화 해야한다.
    // 화면 변화와 같이 의도대로 동작했는지 검증한다.
    expect(spyCancelAnimaionFrame).toHaveBeenCalled();
    expect(document.getElementById("user_input").value).toBe("");
    expect(spyInitCanvasVars).toHaveBeenCalledTimes(1);
    expectedPageStyle({ home: "flex", fireworks: "none" });
});

 

테스트 결과

npx jest .__test__/index.test.js

 

전체 테스트 결과

모든 테스트를 완료했으니 전체 테스트를 한 번에 진행하여 마지막 점검을 하도록 한다.

 npm test

 

통합테스트와 단위테스트가 모두 통과되었으므로 프로젝트의 핵심 흐름이 안정되고 신뢰할 수 있음을 알 수 있다.

 

Github Repo
 

GitHub - jinsk9268/text-fireworks

Contribute to jinsk9268/text-fireworks development by creating an account on GitHub.

github.com