TIL

23/12/04 TIL __ 테스트 코드(Jest)

GABOJOK 2023. 12. 4. 23:53

해야할 일은?__ > 단위테스트코드 작성 __> 테스트에 대응하는 실제코드 작성.

단위테스트 (Unit 테스트)

개발자가 개발한 코드 단위.
소스 코드의 개별 단위를 테스트 해서 사용할 준비가 되었는지 확인하는 거임.
개발 라이프 사이클의 초기단계에 버그 식별이 가능.
메소드를 테스트 하는 다른 메소드임.
독립적이여야 함.
어떤 테스트도 다른 테스트에 의존하면 안됨.
격리되어야 함. 테스트 대상이 의존하는 것을 다른것으로 대체해야 한다.
종속성이 있는 다른 클래스들에서 버그 나는거 방지.
    a 프로그램 이상이 생겨서 수정. 그런데 그 안에 있는 aaa 클래스를 수정한거임?
    a 프로그램과  b 프로그램 모두에서 쓰이는 aaa 클래스를 수정해버려서 b도 이상이 생김.
    그런 상황 방지를 위해 unit테스트 

Jest

페북에서 만든 테스팅 프레임 워크임
최소한의 설정으로 동작.
단위 테스트를 위해 이용.

jest가 test 파일을 찾을때
1. 파일네임에 test.js 가 있으면 알아서 찾아감.
2. 파일 네임에 spec.js가 있으면 알아서 찾아감.
3. 폴더 네임에 tests 라는게 있으면 알아서 찾아감

Jest의 파일 구조
discribe 안에 test 코드들이 존재.
it이라는 단어로 표현하기도 함.

여기서 discribe란
여러 관련 테스트를 그룹화 하는 블록을 만든다.
한마디로 그룹. 묶음

it 혹은 test
개별 테스트를 수행하는 곳. 각 테스트를 작은 문장처럼 설명한다.

test 코드 내부를 보면 expect와 matcher가 존재하는데

  • expect
    • 값을 테스트 할때마다 사용
    • 혼자서 사용 거의 안함 matcher와 함께씀.
  • matcher
    • 여러 방법으로 테스트함.
    • toBe() : 괄호 안에 값이여야 함.
    • not.toBe() : 괄호안에 값이 아니여야 함.
  • jest.fn()

    • mock함수를 생성하는 함수.

    • mock 이란?

      • 가짜, 모의, 흉내내는 이런 뜻이 있는데
      • 단위테스트 작성시, 해당 코드가 의존하고 있는 부분을 가짜로 대체하는 일을 해준다.
      • 스파이. 역할.
    • 의존적인 부분의 상태에 따라 테스트 하고자 하는 부분의 테스트 결과가 영향받을 수 있다.

예를 들어보자.
만약 node.js에서 데이터를 전송하는 과정을 테스트 하려고 하는데,
사실 그 과정에는, 인풋 아웃풋작업, 트랜젝션 생성, 쿼리 전송 등
거기에 데이터베이스 변경 데이터를 직접 원복하거나 트랜젝션 rollback 해줘야 하는 등
너무 많은 과정이 있기 때문에 비 효율적.

또한 테스트 가운데 디비가 잠깐 죽었다면?
결과에 영향이 미치게 된다.

그닥 효율적이여 보이진 않는다.

이럴때 jset.fn()을 이용해 mock 함수 사용한다.

mock 함수는
- 함수내에 어떻게 사용되는지,
- 어떤 일이 발생했는지,
- 어떻게 호출 됬는지 기억함.

mock 함수 만들기
jest.fn() 을 사용하면 만들어짐.

const mockFunction = jest.fn();

//가짜함수 호출시 인자를 넘길수 있다.
mockFunction('hi')

//가짜 함수가 어떤 결과값을 반환할지 직접 알려주기.
mockFunction.mockReturnValue('가짜 함수 호출했어요.')

//가짜 함수가 어떤 값과 함께 호출이 되었는지 검증.
expect(mockFunction).toBeCalledWith('hi')

//가짜 함수가 몇번 호출되었는지 검증
expect(mockFunction).toBeCalledTimes(1)
let mockPrisma = {
    posts: {
        findMany: jest.fn(),
        findUnique: jest.fn(),
        create: jest.fn(),
        update: jest.fn(),
        delete: jest.fn(),
    },
};


test("deletePost Method By Success", async () => {
    const samplePost = {
        postId: 2,
        nickname: "응응디",
        password: "1234",
        title: "어쩔쩔티비 저쩔쩔티비",
        content: "임시 테스트 코드용 내용",
        createdAt: "2023-08-25T03:43:20.5322",
        updatedAt: "2023-08-25T03:43:20.5322",
    };

mockPostsRepository.findPostById.mockReturnValue(samplePost);

TDD

Test Driven Development (테스트 주도 개발)
  1. 테스트 작성 먼저 하며, 결과는 빨간색이여야 함.
  2. 테스트 실패 이후 개발자는 필요한 코드를 작성하여 테스트를 통과시킴. 이때 결과는 녹색이여야 함.
  3. 리펙터링하며 코드 정리, 및 최적화 작업 수행.
  4. 코드 가독성을 높이고, 중복 제거및 코드 품질 향상.

보통 만들때 어떤걸 만들지 생각하고, 어떻게 해야할지 생각한 후 바로 작성하는데
그 전에 테스트 코드를 작성한다.

즉 작성하지도 않은 코드를 예상하며 테스트 코드를 먼저 작성하는 것.
따라서 헷갈리고 어려울 수 있다.

일단 아래와 같이 구성된다.

describe("이 묶음테스트코드는 어떤것에 대한건지 설명쓰기", () => {
    test('테스트에 대한 설명 적기', ()=>{
        expect(테스트 하려고 하는 대상).toBe("원하는 결과를 적기 ")
    })
    //그래서 이렇게 테스트를 하면 원하는 결과와 대상이 부합하면 통과, 
    //그게 아니라면 실패를 보여준다.
});

단위테스트는 직접 영향을 받으면 안되기에 mock 함수 사용한다.

import productController from "./controller/products";
import productModel from "./models/Product";

//mock함수 생성 __ 스파이 심어둠.   
//productModel.creat 라는 애가 호출 됬는지 안됬는지 지켜보고 있다.
productModel.creat = jest.fn();

describe("Product Controller Create", () => {
    test("should have a createProduct function", () => {
        expect(typeof productController.createProduct).toBe("function");
    });
    test("should cll ProductModel.creat", ()=>{
        productController.createProduct();
        expect(productModel.creat).toBeCalled();
    })
});

자 근데 이제 req, res, next 이런애들 써야하는데,
직접 영향 안받으려고 빼버렸는데 어떻게 접근해야 할까?
아래의 패키지를 다운받거나 아니면 mock 함수를 이용해 임으로 설정해주거나 하는 방법이 있다.

node-mocks-http 패키지는
Express 애플리케이션의 요청(request) 및 응답(response) 객체를 손쉽게 만들고
모의(mock)할 수 있는 라이브러리
이 패키지는 테스트 코드에서 Express 요청 및 응답 객체를 생성하고
다양한 HTTP 요청에 대한 응답을 테스트하는 데 유용함

jest.fn() 으로 모의함수 만들어 사용 가능하지만, 이 라이브러리를 쓰는것도 좋아보임.
아래와 같이 쓰기만 하면됨
라이브러리 설치 후 import 해와서,

const req = httpMocks.createRequest();
const res = httpMocks.createResponse();
const next = null

만약 node-mocks-http안쓴다 하면
아래처럼 사용 가능.

const mockRequest = {
    body: jest.fn(),
};

const mockResponse = {
    status: jest.fn(),
    json: jest.fn(),
};

const mockNext = jest.fn();

오케 여기까지 했으면 이제 req, res, next 객체들을 사용할 수 있게 되었다.
그런데 이런 경우를 생각해 보자.
만약 req.body에서 데이터를 받아와야 하는데, 그 부분을 어떻게 검증할 것인가>
req는 임으로 만든 가짜 객체일 뿐인데?
그럴땐 아래와 같이 하면 된다.

req.body = {
    nickname: "Nickname_Success",
    password: "Password_Success",
    title: "Title_Success",
    content: "Content_Success",
}

productController.createProduct(req, res, next)

//가짜로 만든 req.body의 내용을 toBeCalledWith에 넣어준다. 
//왜냐면 product.js파일에서 실제로 호출이 될때, 
//해당 함수의 파라미터로 req.body가 들어가기 떄문
expect(productModel.create).toBeCalledWith(newProduct);
beforeEach

테스트시 공통된 부분을 여기에 넣어주면 된다.
모든 테스트가 실행되기 전에 이부분 먼저 실행됨.
discribe 안에 넣어도 되고, 그보다 밖에 둬도 된다.

describe("Posts Controller Unit Test", () => {
    // 각 test가 실행되기 전에 실행됩니다.
    beforeEach(() => {
        jest.resetAllMocks(); // 모든 Mock을 초기화합니다.

        // mockResponse.status의 경우 메서드 체이닝으로 인해 
        //반환값이 Response(자신: this)로 설정되어야합니다
        mockResponse.status.mockReturnValue(mockResponse);

    });
});

또다른 예를 보자면

import productController from "./controller/products"

let req, res, next;

//전역으로 쓰이는 애들 여기에 담기.
beforeEach(() => {
    req = httpMocks.createRequest();
    res = httpMocks.createResponse();
    next = jest.fn();
});

describe("Product Controller Create", () => {

자 이제 데이터를 저장했으니 , 그 결과값을 클라이언트에게 보여주는 작업을 테스트 코드 작성 후 코드를 작성해 보자.

여기서 잠깐.
왜 테스트 코드를 아래처럼 분리해서 하는걸까?

  test("should call ProductModel.create", () => {
    productController.createProduct(req, res, next);
    expect(productModel.create).toBeCalledWith(newProduct);
  });

  text("should return 201 response code", ()=>{
    productController.createProduct(req, res, next);
    expect(res.statusCode).toBe(201)
  }

테스트 함수는 특정 조건, 동작에 대해 명확하게 정의되고 테스트 되어야 한다.
분리함으로 가독성, 유지보수성을 높인다.
만약 한군데에 합친다면, 테스트가 복잡해 질 수 있고,
실패한 경우 어떤 동작이 실패했는지 파악하기 어렵다.

따라서 각각의 테스트 케이스가 명확하게 정의되고, 특정 기능이나 동작을 분리해서 테스트 하는것이 좋다.


자 이제 status 및 json 응답을 어떻게 테스트 처리 후 코드를 작성할 것인가에 대한 부분을 살펴보자.

만약 node-mocks-http 를 사용하지 않는다면 아래처럼 작성할 수 있겠다.

expect(mockResponse.status).toHaveBeenCalledTimes(1);
expect(mockResponse.status).toHaveBeenCalledWith(200); //상태코드는 200으로.

expect(mockResponse.json).toHaveBeenCalledTimes(1);
expect(mockResponse.json).toHaveBeenCalledWith({
    data: samplePosts,
});

그러나 node-mocks-http를 사용한다면,

expect(res.statusCode).toBe(201);

//_isEndCalled()는 node-mocks-http에서 res객체에 제공되는 내장 메서드
//send()나 end(), json()같이 응답이 제대로 처리 되었는지 확인하는 거임.

//toBeTruthy()는 주어진 값이 참인지 판단하고 참이라면 통과시킴
expect(res._isEndCalled()).toBeTruthy();

이런식으로 간단하게 구현할 수 있다.


이제 생성한 데이터를 리턴하면서 보여주는 작업을 해보자.

테스트 코드에서 가짜함수가 결과값을 어떤걸 반환해야 하는지 직접 지정해 줄때

mockReturnValue 를 사용

잠깐 !

_ getJSONData()는 응답 객체에서 JSON 데이터를 가져오고,
_ isEndCalled()는 응답 객체의 완료 여부를 true, false로 알려주는 애

방금 생성한 데이터를 받아올때
node-mocks-http 사용하면 아래처럼 사용 가능.

test("should return json body in response", () => {
    //mock함수로 만든 productModel.create가 실행되면 값은 newProduct가 나와야 한다.
    productModel.create.mockReturnValue(newProduct);

    //import로 가져온 productController에 있는 createProduct 함수를 호출하는데
    productController.createProduct(req, res, next);

    expect(res._getJSONData()).toStrictEqual(newProduct)
});

그냥 mock 함수만으로 한다면

expect(mockResponse.json).toHaveBeenCalledTimes(1);
expect(mockResponse.json).toHaveBeenCalledWith({
    data: createdPostReturnValue,
});

테스트 코드에서도 비동기 처리를 해줘야 한다.

mock 함수가 아니라 연결한 컨트롤러 부분에 해줘야함.


테스트 를 실행할때 입력해야할 값 일부를 빼먹으면 아무런 에러도 나오지 않고 무한로딩
즉 행이 걸린다.

따라서 에러가 났을 경우에 대한 부분도 테스트 코드 작성 및 실제 코드 작성을 해야한다.

우리가 테스트 코드를 몽고디비 혹은 aws rds 및 프리즈마 에서 사용할 때 해당 부분에서 문제가 일어나지 않는다는 것을 가정하고 작성한 단위테스트 코드이기 때문에,
해당 데이터베이스에서 처리하는 에러 메세지 부분은 mock으로 처리한다.

일단 async await 방식으로 하면서, node-mocks-http 사용하지 않은 경우

test("createPost Method by Invalid Params Error", async () => {
    mockRequest.body = {
        nickname: "Nickname_InvalidParamsError",
        password: "Password_InvalidParamsError",
    };

    await postsController.createPost(mockRequest, mockResponse, mockNext);
    expect(mockNext).toHaveBeenCalledWith(new Error("InvalidParamsError"));

});
//에러 핸들링 테스트코드
test("should handle errors", async () => {
    const errorMessage = { message: "description property missing" };
    const rejectedPromise = Promise.reject(errorMessage);
    productModel.create.mockReturnValue(rejectedPromise);

    await productController.createProduct(req, res, next);
    expect(next).toBeCalledWith(errorMessage);
});

통합 테스트

왜할까?
모듈의 상호작용이 잘 이루어 지는지 검증.
통합과정에서 발생할 수 있는 오류를 찾기 위해.

Supertest 를 이용해서 진행한다.
그러나 jest를 통해서도 가능.