[Canvas-불꽃놀이-20] Particle 클래스 단위 테스트

이전 글 : [Canvas-불꽃놀이-19] CanvasOption 단위 테스트

 

[Canvas-불꽃놀이-19] CanvasOption 단위 테스트

이전 글 : [Canvas-불꽃놀이-18] utils.js 단위 테스트 [Canvas-불꽃놀이-18] utils.js 단위 테스트이전 글 : [Canvas-불꽃놀이-17] Jest 설정과 테스트 진행 순서 [Canvas-불꽃놀이-17] Jest 설정과 테스트 진행 순

jinsk-joy.tistory.com

 

Particle 클래스 단위 테스트 진행

 

Particle 클래스 단위 테스트

Particle.js 전체 코드 확인

더보기
import { randomFloat, setRgbaColor } from "@/js/utils.js";
import { PARTICLE } from "@/js/constants.js";

class Particle {
    /**
     * 모든 파티클의 부모 클래스
     * @param {object} params
     * @param {CanvasRenderingContext2D} params.ctx
     * @param {boolean} params.isSmallScreen
     * @param {number} [params.x]
     * @param {number} [params.y]
     * @param {number} [params.vx]
     * @param {number} [params.vy]
     * @param {number} [params.radius]
     * @param {number} [params.opacity]
     * @param {number} [params.friction]
     * @param {number} [params.color]
     */
    constructor({ ctx, isSmallScreen, x, y, vx, vy, radius, opacity, friction, color }) {
        this.ctx = ctx;
        this.isSmallScreen = isSmallScreen;
        this.initParticleVars({ x, y, vx, vy, radius, opacity, friction, color });

        this.initialState = {};
    }

    initParticleVars(params = {}) {
        const {
            x = 0,
            y = 0,
            vx = 0,
            vy = 0,
            radius = PARTICLE.RADIUS,
            opacity = randomFloat(PARTICLE.MIN_OPACITY, PARTICLE.MAX_OPACITY),
            friction = PARTICLE.FRICTION,
            color = setRgbaColor(PARTICLE.RGB, opacity),
        } = params;

        this.x = x;
        this.y = y;
        this.vx = vx;
        this.vy = vy;
        this.radius = Math.max(radius, 0) * (this.isSmallScreen ? PARTICLE.RADIUS_ADJUST_RATIO : 1);
        this.opacity = opacity;
        this.friction = friction;
        this.fillColor = color;
    }

    draw() {
        this.ctx.beginPath();
        this.ctx.fillStyle = this.fillColor;
        this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
        this.ctx.fill();
        this.ctx.closePath();
    }

    updateVelocity() {
        this.vx *= this.friction;
        this.vy *= this.friction;
    }

    updatePosition() {
        this.x += this.vx;
        this.y += this.vy;
    }

    update() {
        this.updateVelocity();
        this.updatePosition();
    }

    /**
     * 파티클 공통 멤버변수 초기화 및 재사용을 위한 리셋
     * @param {object} [params]
     */
    reset(params = {}) {
        for (const key in this.initialState) params[key] = this.initialState[key];
        this.initParticleVars(params);
    }

    /**
     * @param {number} [opacityLimit]
     * @returns 파티클의 투명도가 제한 기준 이하면 true를 반환, 초과면 false를 반환
     */
    belowOpacityLimit(opacityLimit = 0) {
        return this.opacity <= opacityLimit;
    }
}

export default Particle;

모든 하위 파티클에 대한 부모 클래스인 Particle.js의 단위 테스트를 진행하도록 한다.

 

1.  공통 설정

생성자에 사용되는 메서드 mock 처리

  • Particle 클래스 멤버변수 기본값 설정 시 randomInt, setRgbaColor 메서드를 사용하는 변수가 있다. 이 메서드들은 이전에 검증되었으므로 mock 함수 처리를 해준다.
  • (추가) 멤버변수 기본값 설정 시 사용된 함수의 호출여부 검증을 위해 utils.js에서 검증에 사용할 실사용 함수를 사용할 수 있게 한다.
// utils.js
/**
 * @param {number | string | boolean | undefined} value
 * @returns 파라미터 값이 undefined면 true 반환, 아니면 false 반환
 */
export function isUndefined(value) {
    return value === undefined;
}
// Particle.test.js
jest.mock("@/js/utils", () => {
    // isUndefined 실사용 가능하도록 설정
    const { isUndefined } = jest.requireActual("@/js/utils");
    
    // isUndefined 실사용 | randomFloat, setRgbaColor mock 함수화
    return {
        isUndefined,
        randomFloat: jest.fn(),
        setRgbaColor: jest.fn(),
    };
});

공통 상수 추가

  • constants.js 인자 전달을 하지 않을 시 default 값으로 설정되는 객체를 상수로 추가한다.
// constants.js
const DEFAULT_PARTICLE_COLOR = setRgbaColor(PARTICLE.RGB, PARTICLE.MIN_OPACITY);
export const TEST_OPTION = {
    // 생략...
    
    // Particle 멤버변수 기본값
    PARTICLE_DEFAULT_VALUES: {
        x: 0,
        y: 0,
        vx: 0,
        vy: 0,
        radius: PARTICLE.RADIUS,
        opacity: PARTICLE.MIN_OPACITY,
        friction: PARTICLE.FRICTION,
        color: DEFAULT_PARTICLE_COLOR,
    },
}

setup.js에 반복되는 로직 분리

  • jest-canvas-mock을 사용한 ctx 생성도 공통적으로 들어가는 부분이니 분리하도록 한다.
  • 파티클의 전체 멤버 변수의 값을 확인하는 일이 많아 이 부분을 분리하여 중복을 제거하도록 한다.
// setup.js
/**
 * @returns {CanvasRenderingContext2D} jest-canvas-mock의 ctx 반환
 */
export const createMockCanvasCtx = () => {
    const canvas = document.createElement("canvas");
    return canvas.getContext("2d");
};

/**
 * 파티클의 멤버 변수값이 예측 결과값과 일치하는지 검증
 * @param {Particle} particle
 * @param {object} expectedResult
 */
function expectAllParticleVars(particle, expectedResult) {
    expect(particle.x).toBe(expectedResult.x);
    expect(particle.y).toBe(expectedResult.y);
    expect(particle.vx).toBe(expectedResult.vx);
    expect(particle.vy).toBe(expectedResult.vy);
    expect(particle.radius).toBe(expectedResult.radius);
    expect(particle.opacity).toBe(expectedResult.opacity);
    expect(particle.friction).toBe(expectedResult.friction);
    expect(particle.fillColor).toBe(expectedResult.color);
}

테스트 공통 설정 추가

  • Particle 생성시 꼭 필요한 ctx, isSmallScreen은 전역적으로 사용하도록 한다.
  • mock처리한 randomInt, setRgbaColor은 기본값을 반환하도록 설정한다. 값이 변경될 여지는 없으므로 beforeAll에서 설정한다.
  • ctx, isSmallScreen은 새로운 테스트를 진행할 때마다 초기화되어 똑같은 조건에서 테스트를 진행할 수 있도록 beforeEach에서 설정한다.
// Particle.test.js

describe("Particle 클래스 테스트", () => {
    // 전역 사용 변수
    let ctx;
    let isSmallScreen;
    const { PARTICLE_DEFAULT_VALUES } = TEST_OPTION;

    // 한번만 설정하고 테스트 진행시 처음 설정된 값으로 진행된다.
    beforeAll(() => {
        // 기본값 설정에 쓰이는 메서드 지정한 반환값 반환하도록 설정
        randomFloat.mockReturnValue(PARTICLE_DEFAULT_VALUES.opacity);
        setRgbaColor.mockReturnValue(PARTICLE_DEFAULT_VALUES.color);
    });

    // 매번 테스트 진행 전 새롭게 생성된다.
    beforeEach(() => {
        // jest-canvas-mock 사용
        ctx = ctx = createMockCanvasCtx();
        
        // 기본적으로 false로 설정하고 필요시 테스트 내에서 true로 변경
        isSmallScreen = false;
    });
    
    // 테스트 후 등록한 스파이의 호출기록 일괄적으로 초기화
    afterEach(() => {
        jest.clearAllMocks();
    });
    
    // 생략...
})

 

2. Particle 생성자와 멤버변수 초기화 테스트

  • 파티클의 멤버변수가 전달된 값으로 설정되는지 확인한다.
  • 스크린 크기, 기본값, 기본값 + 커스텀값, 커스텀값으로 구성된 조합으로 무작위성을 검증한다.

  • PC 화면, 기본값, 커스텀값, 기본값 + 커스텀값으로 구성된 조합으로 무작위성을 검증한다.
  • (추가) 값뿐만 아니라 멤버변수 기본값 설정 시 사용된 함수의 호출 여부도 확인해 검증 과정을 강화한다.
// Particle.test.js
test.each([
    { params: {}, notice: "기본값" },
    {
        params: { x: 20, y: 20, radius: 5, opacity: 0.8, friction: 0.95, color: "rgba(255, 255, 255, 1)" },
        notice: "커스텀값",
    },
    { params: { x: 30, y: 30, vx: 2, vy: 2, radius: 4 }, notice: "기본값 + 커스텀값" },
])("Particle 생성자와 멤버변수 초기화 (PC화면 | $notice)", ({ params }) => {
    const particle = new Particle({ ctx, isSmallScreen, ...params });
    
    const expectedResult = { ...PARTICLE_DEFAULT_VALUES, ...params };
    expectAllParticleVars(particle, expectedResult);
    
    // 추가 (기본값 설정 시 사용된 함수 호출여부 확인)
    if (isUndefined(params.opacity)) expect(randomFloat).toHaveBeenCalled();
    if (isUndefined(params.color)) expect(setRgbaColor).toHaveBeenCalled();
});

 


  • 모바일 화면, 기본값 + 커스텀 값으로 구성된 조합을 검증하도록 한다.
    위에서 모든 경우가 검증되었기 때문에 기본값+커스텀값만 검증하도록 한다.
// Particle.test.js
test("Particle 생성자와 멤버변수 초기화 (모바일 화면 | 기본값 + 커스텀값)", () => {
    const params = { isSmallScreen: true, x: 10, y: 10, radius: 2, opacity: 0.5, color: "hsla(60, 100%, 100%)" };
    const particle = new Particle({ ctx, isSmallScreen, ...params });

    const expectedResult = { ...PARTICLE_DEFAULT_VALUES, ...params };
    expectedResult.radius *= PARTICLE.RADIUS_ADJUST_RATIO;
    
    expectAllParticleVars(particle, expectedResult);
});

  • 테스트 시 반지름에 음수를 넣는 경우도 생길 수 있다는 걸 인지하게 됐다. ctx.arc 메서드를 실행할 때 반지름이 음수이면 캔버스에 그리지 않는다. 좀 더 찾아보니 반지름이 음수인 경우는 존재하지 않으므로 캔버스에 똑같이 그리진 않지만 논리적으로 오류가 없는 0으로 바꿔주는 것이 좋다.
// Particle.js
initParticleVars(params = {}) {
    // 생략...
    
    // radius가 음수일 시 0을 적용한다.
    this.radius = Math.max(radius, 0) * (this.isSmallScreen ? PARTICLE.RADIUS_ADJUST_RATIO : 1);
}
// Particle.test.js
test("Particle 생성시 반지름이 음수일 때", () => {
    const particle = new Particle({ ctx, isSmallScreen, radius: -2 });

    expect(particle.radius).toBe(0);
});

 

3.  draw 테스트

  • 생성한대로 메서드가 호출되는지, 중복 호출은 없는지, 인자로 전달한 값으로 잘 생성되는지 검증한다.
// Particle.test.js
test("Particle draw 테스트", () => {
    const [x, y, radius, color] = [100, 100, 10, "#171717"];
    const particle = new Particle({ ctx, isSmallScreen, x, y, radius, color });

    particle.draw();

    // ctx.beginPath가 실행되고 1번만 호출되었는지 확인
    expect(particle.ctx.beginPath).toHaveBeenCalledTimes(1);

    expect(particle.ctx.fillStyle).toBe(particle.fillColor);

    // ctx.arc가 실행되고 1번만 호출되었는지 확인
    expect(particle.ctx.arc).toHaveBeenCalledWith(x, y, radius, 0, Math.PI * 2);
    expect(particle.ctx.arc).toHaveBeenCalledTimes(1);

    // ctx.fill이 실행되고 1번만 호출되었는지 확인
    expect(particle.ctx.fill).toHaveBeenCalledTimes(1);

    // ctx.close가 실행되고 1번만 호출되었는지 확인
    expect(particle.ctx.closePath).toHaveBeenCalledTimes(1);
});

 

4. update 테스트

  • update 메서드는 속도 업데이트와 좌표 업데이트로 이루어져 있으므로 이 둘을 먼저 검증한다.
// Particle.test.js

// 속도
test("Particle update 테스트 (속도)", () => {
    const [vx, vy, friction] = [10, 10, 0.9];
    const particle = new Particle({ ctx, isSmallScreen, vx, vy, friction });

    particle.updateVelocity();

    expect(particle.vx).toBe(vx * friction);
    expect(particle.vy).toBe(vy * friction);
});

// 위치
test("Particle update 테스트 (위치)", () => {
    const [x, y, vx, vy] = [100, 200, 10, 20];
    const particle = new Particle({ ctx, isSmallScreen, x, y, vx, vy });

    particle.updatePosition();

    expect(particle.x).toBe(x + vx);
    expect(particle.y).toBe(y + vy);
});

  • update 메서드는 위에서 검증한 updateVelocity, updatePosition을 실행하는 통합 메서드이다.
// Particle.test.js
test("Particle update 테스트 (통합)", () => {
    const particle = new Particle({ ctx, isSmallScreen, x: 1, y: 1, vx: 10, vy: 10 });

    // updateVelocity, updatePosition이 호출되는지 감시하기위해 jest spy로 등록
    let spyUpdateVelocity = jest.spyOn(particle, "updateVelocity");
    let spyUpdatePostion = jest.spyOn(particle, "updatePosition");

    // 업데이트 실행
    particle.update();

    // update 메서드를 구성하고있는 updateVelocity, updatePosition이 한번씩 호출되는지 검증
    expect(spyUpdateVelocity).toHaveBeenCalledTimes(1);
    expect(spyUpdatePostion).toHaveBeenCalledTimes(1);
});

 

5. reset 테스트

  • Particle의 reset 메서드는 두 가지 역할을 수행한다.
    1. 사용된 파티클의 속성들을 기본값과 파티클 고윳값(initialState)으로 초기화하여 풀에 반납하는 역할을 수행
    2. 초기화된 파티클을 풀에서 가져와 전달된 값으로 파티클의 속성을 초기화하는 역할을 수행
  • 이에 각 역할에 맞는 테스트를 생성해서 검증하도록 한다.

  • 사용된 파티클을 풀에 반납할 때 initialState이 유무를 검증한다.
// Particle.test.js
test.each([
    { initialState: {}, notice: "initialState 없음" },
    { initialState: { vx: 5, vy: 5 }, notice: "initialState 있음" },
])("Particle reset 테스트 (사용된 파티클 풀에 리턴시 초기화 | $notice)", ({ initialState }) => {
    // 사용된 파티클을 초기화 하는것이니 인자를 전달해 테스트 파티클을 생성하도록 하고 initialState도 지정한다.
    const particle = new Particle({ ctx, isSmallScreen, x: 10, y: 10, vx: 1, vy: 1 });
    particle.initialState = initialState;

    // 리셋을 진행하고 예상 결과를 생성한다.
    particle.reset();
    const expectedResult = { ...PARTICLE_DEFAULT_VALUES, ...initialState };

    // 파티클 값과 예상 결과값이 일치하는지 확인한다.
    expectAllParticleVars(particle, expectedResult);
});

  • 풀에서 파티클을 가져와 전달받은 파라미터 값으로 파티클의 속성값을 지정한다.
// Particle.test.js
test.each([
    {
        params: { x: 5, y: 5, vx: 2, vy: 2, radius: 0.8, opacity: 0.5, friction: 0.6, color: "rgba(0, 0, 0, 1)" },
        initialState: {},
        notice: "initialState 없음",
    },
    {
        params: { x: 10, y: 10, vx: 20, vy: 20, radius: 2, opacity: 0.9 },
        initialState: { friction: 0.6, color: "rgba(0, 0, 0, 1)" },
        notice: "initialState 있음",
    },
])("Particle reset 테스트 (풀에서 꺼내와서 재사용 | $notice)", ({ params, initialState }) => {
    // 초기화된 파티클을 생성해서 테스트 진행
    const particle = new Particle({ ctx, isSmallScreen });
    particle.initialState = initialState;

    // 재사용을 위해 인자값을 전달해서 파티클의 속성을 초기화하도록 한다.
    particle.reset(params);
    
    // 예측 결과값 (기본값 객체을 먼저 베이스로 두고, 인자로 전달된 객체, initialState로 덮어야 함)
    const expectedResult = { ...PARTICLE_DEFAULT_VALUES, ...params, ...initialState };

    // 재사용 파티클의 속성값과 예측 결과를 검증한다.
    expectAllParticleVars(particle, expectedResult);
});

 

6. belowOpacityLimit 테스트

  • 투명도 제한도 다양한 케이스를 통해 무작위성을 검증한다. (undefined는 기본값 적용)
// Particle.test.js
test.each([
    { opacity: 1, opacityLimit: 0.5, expectedResult: false, notice: "커스텀 적용" },
    { opacity: 0.1, opacityLimit: 0.5, expectedResult: true, notice: "커스텀 적용" },
    { opacity: -0.004, opacityLimit: 0.1, expectedResult: true, notice: "커스텀 적용" },
    { opacity: 0.5, opacityLimit: undefined, expectedResult: false, notice: "기본값 0 적용" },
    { opacity: -0.01, opacityLimit: undefined, expectedResult: true, notice: "기본값 0 적용" },
])("Particle 투명도 제한 기준 테스트: $opacity < $opacityLimit (opacityLimit $notice)", ({ opacity, opacityLimit, expectedResult }) => {
    const particle = new Particle({ ctx, isSmallScreen, opacity });

    expect(particle.belowOpacityLimit(opacityLimit)).toBe(expectedResult);
});

 

테스트 결과

npx jest ./__test__/particle/Particle.test.js

 

Github Repo
 

GitHub - jinsk9268/text-fireworks

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

github.com