Skip to content

[BE] 테스트 용도의 Redis Docker 생성 및 통합 테스트 진행

JIN edited this page Jan 14, 2025 · 1 revision
⚙️ Web BE
김진
2025.01.08. 작성

📖 테스트 용도의 Redis Docker 생성 및 통합 테스트 진행

테스트 용도의 도커 컨테이너는 어떤 식으로 동작할까?

graph LR
    A[테스트 환경 구축] --> B[컨테이너 실행]
    B --> C[테스트 수행]
    C --> D[컨테이너 종료 후 제거]
    D --> E[테스트 완료]

Loading
  • 테스트를 수행하기 전 필요한 환경을 컨테이너 기반으로 구축
  • 테스트를 수행할 때 생성된 컨테이너에 접근해 테스트
  • 테스트가 종료되면 생성된 컨테이너를 제거

테스트 용도의 도커 컨테이너를 사용했을 때의 장단점

👍🏻 장점

  • 각각의 테스트만을 위한 격리된 환경이므로 테스트 간 발생될 수 있는 간섭을 제거해 신뢰도 향상
  • 로컬, CI/CD 파이프라인 등 어떤 환경에서든 동일한 테스트가 동작할 수 있도록 보장

👎🏻 단점

  • docker 환경이 구축되어 있어야 사용할 수 있음
  • container 생성이 필요해 추가 리소스가 사용됨
  • 독립적인 테스트 환경이 많아질 수록 컨테이너 시작과 종료를 여러 번 수행하기 때문에 시간이 오래 걸림

직접 해보기

# server/src/docker-compose.yml

services:
  redis:
    image: redis:latest
    container_name: redis_test
    ports:
      - "6379:6379"
  • 아직 Redis를 어디에 둘 지 등 결정되지 않은 게 많은 상태여서 server/ 위치에 임시용 파일을 생성했다.

해당 파일을 실행하면 다음과 같이 동작한다.

image

  • -d 는 백그라운드 실행 옵션
  • 윈도우 이용 중이라 wsl 에 docker와 docker-compose를 설치하여 사용할 수 있었다.
    • 별 다른 이유는 없고.. 그냥 Docker desktop 을 깔고 싶지 않았다. 이미 설치되어 있던 wsl 를 활용하고 싶었다.

종료도 정상적으로 동작한다.

image

이를 바탕으로 통합 테스트부터 진행해보자! (소켓 때문에…. 아직 e2e는… 🥲)

실패 사례와 해결 과정 1

열심히 코드를 작성하고 확인해보았는데 자꾸 오류가 발생했다.

    thrown: "Exceeded timeout of 5000 ms for a hook.
    Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

      39 |   });
      40 |
    > 41 |   beforeEach(async () => {
         |   ^
      42 |     client = {
      43 |       handshake: {
      44 |         auth: { roomId: '', playerId: '' },

      at drawing/drawing.gateway.e2e.spec.ts:41:3
      at Object.<anonymous> (drawing/drawing.gateway.e2e.spec.ts:10:1)

beforeEach 부분에서 계속 timeout이 발생해 console.log 를 작성하면서 확인해본 결과….

// drawing.gateway.e2e.spec.ts - beforeEach 부분
  beforeEach(async () => {
    client = {
	    // ...
    } as any;
    console.log('client 완료');
    
    console.log('room hash 세팅 시작');
    await redisService.hset('room:room1', { roomId: 'room1' });
    console.log('room hash 세팅 완료');
    await redisService.hset('room:room1:players:player1', { playerId: 'player1' });
  });

room hash 세팅 완료 라는 콘솔 메시지가 출력되지 않는 것을 확인했다.

그래서 코드를 살짝 바꿔서 hset 만 되지 않는 것인지, 그냥 redis 연결이 안됐는지를 확인해 보았다.

  // drawing.gateway.e2e.spec.ts - beforeEach 부분
  beforeEach(async () => {
    client = {
	    // ...
    } as any;
    console.log('client 완료');
    console.log('redis 연결 확인: ' + (await redisService.checkConnection()));

    await redisService.hset('room:room1', { roomId: 'room1' });
    await redisService.hset('room:room1:players:player1', { playerId: 'player1' });
  });
  
  // ...
  
  // redis.service.ts - checkConnection 부분
  async checkConnection() {
    return (await this.redis.ping()) === 'PONG';
  }

image

이미지처럼 client 완료 메시지는 제대로 출력되지만 redisService 를 사용하는 부분은 동작하지 않는다는 것을 확인했다.

문제는 상당히 쉽게.. 해결할 수 있었다ㅠㅠ(별건 아닌데 소요시간은 오래 걸렸다..ㅠㅠ)

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        DrawingGateway,
        DrawingService,
        DrawingRepository,
        RedisService,
        {
          provide: ConfigService,
          useValue: {
            get: jest.fn().mockImplementation((key: string) => {
              if (key === 'REDIS_HOST') return 'redis';
              if (key === 'REDIS_PORT') return '6379';
              return null;
            }),
          },
        },
      ],
    }).compile();

    gateway = module.get<DrawingGateway>(DrawingGateway);
    service = module.get<DrawingService>(DrawingService);
    redisService = module.get<RedisService>(RedisService);
  });

REDIS_HOST 를 service 이름인 redis 로 사용해서 발생한 문제였다.

Docker Compose로 Redis를 실행해서 사용하는 거라 redis 라는 서비스 이름으로 접근해야 한다고 생각했었는데, 수행 중인 테스트는 Docker 컨테이너 안이 아닌 내 로컬 컴퓨터에서 실행해야 하는 거라 redis 라는 이름을 인식하지 못했던 것이다.

해당 부분을 localhost 로 바꾸면서 해결할 수 있었다.

image

이렇게~ 테스트가 실행되는 것을 확인할 수 있었다! 이제 실패한 테스트에 대해서만 해결하면 통합 테스트는 완료된다!

실패 사례와 해결 과정 2

(오늘의 배운점)

it.only() 또는 describe.only() 를 사용하면 해당 테스트 블럭만 실행할 수 있다! 나처럼 하나의 it 만 실패하는 경우에 손쉽게 해당 테스트만 실행하고 확인할 수 있을 것이다ㅎㅎ!

코드는 아래와 같이 작성했다.

    it.only('room과 player가 정상적으로 존재하는 경우', async () => {
      client.handshake.auth = { roomId: 'room1', playerId: 'player1' };
      await gateway.handleConnection(client);

      expect(client.join).toHaveBeenCalledWith('room1');
      expect(client.data.roomId).toBe('room1');
      expect(client.data.playerId).toBe('player1');
    });

코드는 내가 짜서 하는 말이 절대 아니고 진짜 오류 날 부분이 없어보여서 redis에 저장된 값을 먼저 확인해보았다.

문제는 바로 여기에 있었다!

  beforeEach(async () => {
    client = {
      handshake: {
        auth: { roomId: '', playerId: '' },
      },
      data: {},
      join: jest.fn(),
      to: jest.fn().mockReturnValue({
        emit: jest.fn(),
      }),
    } as any;

    await redisService.hset('room:room1', { roomId: 'room1' });
    await redisService.hset('room:room1:players:player1', { playerId: 'player1' });
  });

가장 마지막 줄 코드를 보면 아래처럼 작성이 되어있다.

await redisService.hset('room:room1:**players**:player1', { playerId: 'player1' });

그리고.. 레포지토리에서 확인해보면?

async existsPlayer(roomId: string, playerId: string) {
    const exists = await this.redisService.exists(`room:${roomId}:**player**:${playerId}`);
    return exists === 1;
  }

문제는 바로 player가 아닌 players라고 s를 붙여서 문제가 됐던 것이다! 어쩐지 전혀 오류가 날 부분이 아니었는데… 다음부터는 직접 치지 말고 레포지토리에서 복사 붙여넣기해서 작업하는 편이 훨씬 좋을 거 같다..

전체 코드 및 결과

(e2e 테스트하려고 만든건데 시간 관계상 통합 테스트만 진행해서 파일명을 수정했다.)

import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { Socket } from 'socket.io';
import { RedisService } from '../redis/redis.service';
import { DrawingGateway } from './drawing.gateway';
import { DrawingService } from './drawing.service';
import { DrawingRepository } from './drawing.repository';
import { BadRequestException, PlayerNotFoundException, RoomNotFoundException } from '../exceptions/game.exception';

describe('DrawingGateway 통합 테스트', () => {
  let gateway: DrawingGateway;
  let service: DrawingService;
  let redisService: RedisService;
  let client: Socket;

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        DrawingGateway,
        DrawingService,
        DrawingRepository,
        RedisService,
        {
          provide: ConfigService,
          useValue: {
            get: jest.fn().mockImplementation((key: string) => {
              if (key === 'REDIS_HOST') return 'localhost';
              if (key === 'REDIS_PORT') return '6379';
              return null;
            }),
          },
        },
      ],
    }).compile();

    gateway = module.get<DrawingGateway>(DrawingGateway);
    service = module.get<DrawingService>(DrawingService);
    redisService = module.get<RedisService>(RedisService);
  });

  beforeEach(async () => {
    client = {
      handshake: {
        auth: { roomId: '', playerId: '' },
      },
      data: {},
      join: jest.fn(),
      to: jest.fn().mockReturnValue({
        emit: jest.fn(),
      }),
    } as any;

    await redisService.hset('room:room1', { roomId: 'room1' });
    await redisService.hset('room:room1:player:player1', { playerId: 'player1' });
  });

  // 테스트가 수행될 때마다 DB를 비워줌
  afterEach(async () => {
    await redisService.flushAll();
  });

  describe('handleConnection', () => {
    it('roomId 또는 playerId의 값이 존재하지 않는 경우', async () => {
      const authInfo = [
        { roomId: '', playerId: '' },
        { roomId: 'room1', playerId: '' },
        { roomId: '', playerId: 'player1' },
      ];

      for (const auth of authInfo) {
        client.handshake.auth = auth;
        await expect(gateway.handleConnection(client)).rejects.toThrowError(
          new BadRequestException('Room ID and Player ID are required'),
        );
      }
    });

    it('room이 redis 내에 존재하지 않는 경우', async () => {
      client.handshake.auth = { roomId: 'failed-room', playerId: 'player1' };

      await expect(gateway.handleConnection(client)).rejects.toThrowError(new RoomNotFoundException('Room not found'));
    });

    it('player가 redis 내에 존재하지 않는 경우', async () => {
      client.handshake.auth = { roomId: 'room1', playerId: 'failed-player' };

      await expect(gateway.handleConnection(client)).rejects.toThrowError(
        new PlayerNotFoundException('Player not found in room'),
      );
    });

    it('room과 player가 정상적으로 존재하는 경우', async () => {
      client.handshake.auth = { roomId: 'room1', playerId: 'player1' };
      await gateway.handleConnection(client);

      expect(client.join).toHaveBeenCalledWith('room1');
      expect(client.data.roomId).toBe('room1');
      expect(client.data.playerId).toBe('player1');
    });
  });

  describe('handleDraw', () => {
    it('roomId 값이 존재하지 않는 경우', async () => {
      client.data = {};
      const data = { drawingData: {} };

      await expect(gateway.handleDraw(client, data)).rejects.toThrowError(
        new BadRequestException('Room ID is required'),
      );
    });

    it('정상적으로 그림이 그려지는 경우', async () => {
      client.data = { roomId: 'room1', playerId: 'player1' };
      const data = { drawingData: { pos: 56, fillColor: { R: 0, G: 0, B: 0, A: 0 } } };

      await gateway.handleDraw(client, data);

      expect(client.to).toHaveBeenCalledWith('room1');
      expect(client.to('room1').emit).toHaveBeenCalledWith('drawUpdated', {
        playerId: 'player1',
        drawingData: data.drawingData,
      });
    });
  });
});

image

이전과 달리 모킹하지 않고 직접 gateway.handleConnection() 이나 gateway.handleDraw() 함수를 실행해도 정상적으로 동작하는 것을 확인할 수 있다!

  • REDIS_HOSTREDIS_PORT 는 이후 CI/CD 환경에서 .env 파일을 생성하지 않더라도 원활하게 동작할 수 있게 값을 모킹했다.

🔗 참고 사이트

test container에 대해서 알아보자

[SpringBoot] Redis 테스트 환경 구축하기 (2) - Test Container

Clone this wiki locally