본문 바로가기
Dev/테스트 코드

비동기 코드 단위 테스트

by 싯벨트 2025. 3. 16.
728x90

비동기 코드를 직접 테스트 하면 시간도 오래 걸리고 일관된 결과를 제공하지 못하는 불안정한 코드가 됩니다. 이를 해결하기 위한 방법 2가지, 진입점 분리 패턴어댑터 분리 패턴을 살펴보겠습니다.

1. 비동기 로직 통합 테스트

URL을 통해 웹 사이트가 정상적으로 작동하는지 확인하는 모듈을 테스트하는 방법을 알아보겠습니다. 성공 여부는 “illustrative” 라는 단어의 포함 여부로 판단합니다. 이때 비동기 코드는 콜백과 async/await 2가지 메커니즘으로 구현할 수 있으며, 각 메커니즘으로 구현된 코드와 테스트 코드를 이어서 살펴보겠습니다.

1.1 node-fetch를 사용한 비동기 코드

콜백 패턴을 사용하는 함수는 매개변수로 콜백함수를 사용하고, 성공이나 실패 여부에 따라 매개변수로 받은 콜백함수에 성공, 실패에 해당되는 객체 형태의 매개변수를 전달합니다. 반면, async/await 패턴을 사용하는 함수는 동기적인 흐름으로 코드를 진행합니다.

const fetch = require("node-fetch");
const website = "<http://example.com>";

const isWebsiteAliveWithCallback = (callback) => {
    fetch(website)
        .then((resp) => {
            if (!resp.ok) throw Error(resp.statusText);
            return resp;
        })
        .then((resp) => resp.text())
        .then((text) => {
            if (text.includes("illustrative")) {
                callback({ success: true, status: "ok" });
            } else {
                callback({ success: false, status: "text missing" });
            }
        })
        .catch((err) => {
            callback({ success: false, status: err });
        });
};

// async/await 
const isWebsiteAliveWithAsyncAwait = async () => {
    try {
        const resp = await fetch(website);
        if (!resp.ok) throw resp.statusText;

        const text = await resp.text();
        const isIncluded = text.includes("illustrative");
        if (isIncluded) return { success: true, status: "ok" };
        throw "text missing";
    } catch (err) {
        return { success: false, status: err };
    }
};

1.2 done 콜백

비동기 코드 테스트를 구현하려면, 먼저 done 콜백 함수를 알고 있어야 합니다. done은 비동기 코드가 완료되었음을 제스트에 알리는 콜백함수입니다. test() 함수에 done을 매개변수로 사용하고, done()을 명시적으로 호출하여 제스트에게 테스트가 완료되었음을 알려줍니다. done이 호출되지 않으면 기본 타임아웃(5초) 후에 테스트는 실패합니다.

이어서 비동기 함수 setTimeout()을 통해 done의 사용법을 알아보겠습니다.

1.2.1 done을 쓰지 않음

일반적인 동기 코드 내부에 비동기 코드가 있는 경우와 동일합니다. 비동기 로직인 검증 부분을 기다리지 않고 테스트는 종료됩니다. 성공이지만 의미가 없는 상황입니다.

test("async task test (without done)", () => {
    setTimeout(() => {
        expect(true).toBe(true);
    }, 3000);
});

1.2.2 done을 호출하지 않음

done을 매개변수로 사용했지만, 명시적으로 호출하여 비동기 코드의 완료를 알리지 않았기 때문에 5초 후에 테스트는 실패합니다.

test("async task test (not call done)", (done) => {
    setTimeout(() => {
        expect(true).toBe(true);
    }, 3000);
});

1.2.3 done 호출

done()을 호출하여 비동기 코드의 완료를 제스트가 인지할 수 있게 했습니다. 따라서 테스트는 성공합니다.

test("async task test (using done)", (done) => {
    setTimeout(() => {
        expect(true).toBe(true);
        done();
    }, 3000);
});

1.2.4 타임아웃 시간 변경

타임아웃의 기본값은 5초지만, jest.setTimeout()을 사용하거나, test의 세 번째 매개변수로 입력하여 변경할 수도 있습니다. jest.setTimeout()을 사용하는 경우, test() 밖에서 설정해줘야 테스트를 시작할 때 변경된 기준값이 적용됩니다.

// setTimeout 사용
jest.setTimeout(10000);
test("async task test (using done)", (done) => {
    setTimeout(() => {
        expect(true).toBe(true);
        done();
    }, 6000);
}, 10000); // 매개변수 사용

1.3 통합 테스트

콜백 함수가 종료점일 경우, 테스트를 하려면 콜백 함수를 직접 전달해야 합니다. result에 값이 할당될 때까지 기다려야 하고, done을 호출하여 비동기 로직이 완료되었음을 명시합니다. async/await는 테스트 앞에 async 를 붙이고, 비동기 로직에 await를 붙여서 동기적 흐름을 구성합니다.

describe("web verifier integration test", () => {
    test("success case with callback, returns true ", (done) => {
        samples.isWebsiteAliveWithCallback((result) => {
            expect(result.success).toBe(true);
            expect(result.status).toBe("ok");
            done();
        });
    });

    test("success case with async/await, returns true", async () => {
        const result = await samples.isWebsiteAliveWithAsyncAwait();
        expect(result.success).toBe(true);
        expect(result.status).toBe("ok");
    });
});

1.3.1 통합 테스트의 어려움

통합 테스트는 속도, 안정성, 디버깅, 상황 재현, 신뢰성 측면에서 한계점을 가집니다. 단위 테스트에 비해 느리고, 환경에 따라 성공 여부가 달라질 수 있으며, 다양한 외부적인 요인으로 상황을 파악하거나 재현하여 재현하는 것이 어렵고, 결과적으로 테스트 자체에 대한 높은 신뢰성을 갖기가 어렵습니다.

이를 해결하기 위해 앞서 언급했던 2가지 패턴, 진입점 분리 패턴, 어댑터 분리 패턴을 통해 단위 테스트에 적합한 코드로 구성하는 방법을 살펴보겠습니다.

2. 코드를 단위 테스트에 적합하게 만들기

2.1 진입점 분리 패턴

진입점 분리 패턴비동기 부분과 순수 로직 부분을 분리하여, 비동기 작업이 끝났을 때 호출되는 콜백 함수를 순수 로직 부분의 진입점으로 활용하는 방법입니다.

2.1.1 작업 단위 분리하기: 콜백 함수

앞선 콜백 패턴의 코드를 보면, URL을 통해 웹 사이트의 데이터를 불러오는 fetch()가 비동기 코드이고, 성공과 실패에 따라 콜백을 리턴하는 것은 순수 로직입니다. 따라서 아래와 같이 코드를 리팩터링할 수 있습니다. 이렇게 되면 진입점은 3개로 늘어납니다.

// 진입점 1: 비동기 작업
const isWebsiteAliveWithCallback = (callback) => {
    fetch(website)
        .then(throwOnInvalidResponse)
        .then((resp) => resp.text())
        .then(processFetchSuccess(text, callback))
        .catch(processFetchError(err, callback));
};

const throwOnInvalidResponse = (resp) => {
    if (!resp.ok) throw Error(resp.statusText);
    return resp;
};

// 진입점 2: 비동기 작업의 성공
const processFetchSuccess = (text, callback) => {
    if (text.includes("illustrative")) {
        callback({ success: true, status: "ok" });
    } else {
        callback({ success: false, status: "text missing" });
    }
};

// 진입점 2: 비동기 작업의 실패
const processFetchError = (err, callback) => {
    callback({ success: false, status: err });
};

2.1.2 테스트 코드 작성

성공 케이스에 대한 진입점에서 성공과 실패에 대한 2가지 케이스, 그리고 에러에 대한 진입점에서 에레 메시지를 던지는 케이스, 총 3가지 테스트를 작성할 수 있습니다. 비동기 코드과 분리했기 때문에 동기적인 테스트를 진행하면 되지만, 콜백 함수의 누락이 있거나 에러를 던지는 경우에는 콜백 함수가 호출되지 않을 수 있는데, 이런 경우를 잡아내기 위해서 done을 사용할 수도 있습니다.

describe("web verifier unit test", () => {
    test("content matches, return true", () => {
        samples.processFetchSuccess("illustrative", (result) => {
            expect(result.success).toBe(true);
        });
    });

    test("content does not match, return false", () => {
        samples.processFetchSuccess("not matched content", (result) => {
            expect(result.success).toBe(false);
        });
    });

    test("when fetch fail, throw error", () => {
        samples.processFetchError("error text", (result) => {
            expect(result.status).toBe("error text");
        });
    });
});

2.1.3 작업 단위 분리하기: async/await

콜백 함수가 없으므로 값을 바로 리턴하거나 throw를 통해 에러를 던집니다.

const isWebsiteAliveWithAsyncAwait = async () => {
    try {
        const resp = await fetch(website);
        throwOnInvalidResponse();
        const text = await resp.text();
        return processFetchSuccess(text);
    } catch (err) {
        processFetchError(err);
    }
};

const throwOnInvalidResponse = (resp) => {
    if (!resp.ok) throw Error(resp.statusText);
};

const processFetchSuccess = (text) => {
    if (text.includes("illustrative")) {
        return { success: true, status: "ok" };
    }
    return { success: false, status: "text missing" };
};

const processFetchError = (err) => {
    throw err;
};

2.1.4 테스트 코드

비동기 부분을 분리했기 때문에 async/await 없이 테스트 코드가 작성 가능합니다.

describe("web verifier unit test with using async/await", () => {
    test("content matches, return true", () => {
        const result = samples.processFetchSuccess("illustrative");
        expect(result.success).toBe(true);
    });

    test("content does not match, return false", () => {
        const result = samples.processFetchSuccess("not matched content");
        expect(result.success).toBe(false);
    });

    test("when fetch fail, throw error", () => {
        expect(() => samples.processFetchError("error text")).toThrow(
            "error text"
        );
    });

2.2 어댑터 분리 패턴

어댑터 분리 패턴은 비동기 코드를 의존성처럼 취급하는 전략입니다. 비동기 코드를 분리하여 어댑터로 감싸고, 이를 다른 의존성처럼 주입할 수 있게 합니다. 이때 준수되는 원칙은 인터페이스 분리 원칙(ISP, Interface Segregation Principle)으로, 의존성을 어댑터 뒤에 숨기고, 어댑터의 인터페이스는 사용하는 쪽의 필요에 맞게 단순화하는 원칙입니다.

2.2.1 네트워크 어댑터 모듈 만들기

node-fetch 를 가져오는 비동기 코드를 모듈로 구성하여, 추후 의존성이 변경되어도 해당 모듈만 변경하여 대응할 수 있게 합니다.

const fetchUrlText = async (url) => {
  const resp = await fetch(url);
  if (resp.ok) {
    const text = await resp.text();
    return { ok: true, text: text };
  }
  return { ok: false, text: resp.statusText };
};

2.2.2 모듈을 활용한 작업 코드

앞선 집입점 분리 패턴과 유사합니다.

const isWebsiteAlive = async () => {
    try {
        const result = await network.fetchUrlText(websiteUrl);
        throwOnInvalidResponse(result);
        const text = result.text;
        return processFetchSuccess(text);
    } catch (err) {
        processFetchError(err);
    }
};

const throwOnInvalidResponse = (resp) => {
    if (!resp.ok) throw Error(resp.statusText);
};

const processFetchSuccess = (text) => {
    if (text.includes("illustrative")) {
        return { success: true, status: "ok" };
    }
    return { success: false, status: "text missing" };
};

const processFetchError = (err) => {
    throw err;
};

2.2.3 테스트 코드

jest.mock()을 통해 어댑터 모듈을 모의 객체로 대체했으며, 대체된 모듈을 불러와서 할당한 스텁은 동기적인 진행을 의미하도록 변수명을 지었습니다. 두 번째 테스트인 콘텐츠가 다른 경우의 테스트에서 throwOnInvalidResponse를 통과해야 하므로 ok는 true로 설정해야 합니다.

jest.mock("./network-adapter");
const stubSyncNetwork = require("./network-adapter");
const webVerifier = require("./fetching");

describe("web verifier unit test (ADAPTER PATTERN)", () => {
    beforeEach(jest.resetAllMocks);

    test("content matches, return true", async () => {
        stubSyncNetwork.fetchUrlText.mockReturnValue({
            ok: true,
            text: "illustrative",
        });
        const result = await webVerifier.isWebsiteAlive();
        expect(result.success).toBe(true);
    });

    test("content does not match, return false", async () => {
        stubSyncNetwork.fetchUrlText.mockReturnValue({
            ok: true,
            text: "not matched content",
        });
        const result = await webVerifier.isWebsiteAlive();
        expect(result.success).toBe(false);
    });

    test("when fetch fail, throw error", async () => {
        stubSyncNetwork.fetchUrlText.mockRejectedValue(new Error("error text"));
        await expect(webVerifier.isWebsiteAlive()).rejects.toThrow(
            "error text"
        );
    });
});

2.2.4 함수형 어댑터

함수형 디자인 패턴에서는 네트워크 어댑터 모듈을 매개변수로 주입해줍니다. 이렇게 구현하면, 테스트에서는 네트워크 어댑터 모듈을 가져올 필요 없이, 가짜 어댑터를 삽입할 수 있습니다. 관리할 모듈 수를 줄인다는 측면에서 유지 보수에 유리합니다.

// 코드
const isWebsiteAlive = async (network) => {
    const result = await network.fetchUrlText(websiteUrl);
    if (!result.ok) {
        return processFetchError(err);
    }
    const text = result.text;
    return processFetchSuccess(text);
};

const processFetchSuccess = (text) => {
    if (text.includes("illustrative")) {
        return { success: true, status: "ok" };
    }
    return { success: false, status: "text missing" };
};

const processFetchError = (err) => {
    return { success: false, status: err };
};

// 테스트 코드
const makeStubNetworkWithResult = (fakeResult) => {
    return {
        fetchUrlText: () => {
            return fakeResult;
        },
    };
};

describe("web verifier unit test (ADAPTER FUNCTIONAL)", () => {
    test("content matches, return true", async () => {
        const stubSyncNetwork = makeStubNetworkWithResult({
            ok: true,
            text: "illustrative",
        });
        const result = await webVerifier.isWebsiteAlive(stubSyncNetwork);
        expect(result.success).toBe(true);
    });

    test("content does not match, return false", async () => {
        const stubSyncNetwork = makeStubNetworkWithResult({
            ok: true,
            text: "not matched content",
        });
        const result = await webVerifier.isWebsiteAlive(stubSyncNetwork);
        expect(result.success).toBe(false);
    });

    test("when fetch fail, throw error", async () => {
        const stubSyncNetwork = makeStubNetworkWithResult({
            ok: false,
            text: "error text",
        });
        const result = await webVerifier.isWebsiteAlive(stubSyncNetwork);
        expect(result.status).toBe("error text");
    });
});

2.2.5 객체 지향, 인터페이스 기반 어댑터

타입스크립트를 활용하여 생성자 주입 패턴으로 어댑터 모듈을 주입하고, 인터페이스를 사용할 수 있습니다. 이렇게 의존성 주입(DI, Dependency Injection)제어 역전 (IoC, Inversion of Control) 방식으로 구현하면 의존성 관리 및 유지 보수에 유리합니다.

export interface WebsiteAliveResult {
    success: boolean;
    status: string;
}

export class WebsiteVerifier {
    constructor(private network: INetworkAdapter) {}

    async isWebsiteAlive(): Promise<WebsiteAliveResult> {
        const result: NetworkAdapterFetchResults = await this.network.fetchUrlText(websiteUrl);
        if (!result.ok) {
            return this.processFetchError();
        }

        const text = result.text;
        return this.processFetchSuccess(text);
    }

    processFetchSuccess(text: string): WebsiteAliveResult {
        const included = text.includes("illustrative");
        if (included) {
            return { success: true, status: "ok" };
        }
        return { success: false, status: "missing text" };
    }

    processFetchError(): WebsiteAliveResult {
        return { success: false, status: "error text" };
    }
}

참고자료