본문 바로가기

Side project

[Side project] 축구 동호회 공식 사이트 만들기 #1;

반응형

Intro


사이드 프로젝트 개발을 진행을 하면서 모든 개발 상황을 티스토리에 업로드를 하지는 않겠지만, 대표적으로 몇 개만 올릴 예정이다. 

 

 

유저 회원가입


아직 AOP 관련 기술(인터셉터, 필터, 미들웨어 등)을 도입하지 않았고 또한 토큰 발급도 하지 않았다. 개인적인 생각과 경험으로 처음부터 완벽하게 진행을 하게 되면 하나의 모듈을 만드는데 걸리는 시간이 오래 걸릴 뿐더러 완벽하지도 않다.. 1차, 2차, n차 스프린트 개발을 진행을 하면서 기능 수정, 보완, 추가를 진행하며 필요하다면 리팩토링을 할 때가 길게 봤을 때 가장 효율적이고 성능이 괜찮게 나왔었다. 그래서 이번에 업로드하는 API 또한 기능적으로 완벽하지는 않다.

 

[TDD] Test Case #1; not matched password

DTO로 받을 필드 중에 password, confirmPassword가 존재한다. 이 두 개의 패스워드는 서로 같아야 한다.

 

    it('Case #1: not matched password', async () => {
      // given
      const newUser: CreateUserDto = {
        name: 'test user',
        nickname: 'test',
        email: 'test@test.com',
        userId: 'testId',
        password: '1q2w3e4r1!@',
        confirmPassword: '1q2w3e4r1!@1',
      };

      // then
      await expect(userService.create(newUser)).rejects.toThrow(
        '비밀번호가 일치하지 않습니다.',
      );
    });

 

 

이렇게만 작성을 해두면 외부 모듈을 모킹해둔 것이 없기 때문에 테스트는 당연히 실패하게 될 것이다

이제 서비스 로직을 수정을 해준다

 

  async create(createUserDto: CreateUserDto) {
    try {
      if (createUserDto.password !== createUserDto.confirmPassword) {
        throw new BadRequestException('비밀번호가 일치하지 않습니다.');
      }
    } catch (error) {
      throw error;
    }
  }

 

이제 RED 단계를 지나 GREEN 단계도 완료를 해주었다. REFACTOR 단계도 있지만, 남들이 보기엔 어떨지 모르겠지만.. 내가 보기엔 냄새나는 코드는 없어 보이기 때문에 REFACTOR 단계도 완료가 된거라고 생각하고 넘어간다.

 

 

[TDD] Test Case #2; password length is less than 20

password의 문자열 길이는 20자 이하여야 한다

 

    it('Case #2: password length is less than 20', async () => {
      // given
      const newUser: CreateUserDto = {
        name: 'test user',
        nickname: 'test',
        email: 'test@test.com',
        userId: 'testId',
        password:
          '1q2w3e4r1!@1q2w3e4r1!@1q2w3e4r1!@1q2w3e4r1!@1q2w3e4r1!@1q2w3e4r1!@',
        confirmPassword:
          '1q2w3e4r1!@1q2w3e4r1!@1q2w3e4r1!@1q2w3e4r1!@1q2w3e4r1!@1q2w3e4r1!@',
      };

      // then
      await expect(userService.create(newUser)).rejects.toThrow(
        '비밀번호는 20자 이하로 입력해주세요.',
      );
    });

 

다시 한번 RED 단계를 해주어 실패되는 테스트 코드를 작성한다

 

이제 GREEN 단계를 위해 서비스 코드를 수정을 해준다

  async create(createUserDto: CreateUserDto) {
    try {
      if (createUserDto.password !== createUserDto.confirmPassword) {
        throw new BadRequestException('비밀번호가 일치하지 않습니다.');
      }
      
      if (createUserDto.password.length > 20) {
        throw new BadRequestException('비밀번호는 20자 이하로 입력해주세요.');
      }
    } catch (error) {
      throw error;
    }
  }

 

이제 다시 GREEN 단계를 해주며, 냄새나는 코드를 수정해주어 REFACTOR 단계도 진행해주면 된다

 

[TDD] Test Case #3; password pattern is invalid

패스워드는 보안을 위해 알파벳, 숫자, 특수문자가 각각 2자 이상씩 입력을 해주어야 한다

 

    it('Case #3: password pattern is invalid', async () => {
      // given
      const newUser: CreateUserDto = {
        name: 'test user',
        nickname: 'test',
        email: 'test@test.com',
        userId: 'testId',
        password: 'testpassword',
        confirmPassword: 'testpassword',
      };

      // then
      await expect(userService.create(newUser)).rejects.toThrow(
        '비밀번호는 8자 이상, 영문, 숫자, 특수문자를 2종류 이상 조합해주세요.',
      );
    });

 

 

이제 서비스 코드를 수정해주어야 한다. 정규 표현식을 사용하여 패스워드를 검사를 해주면 된다

 

  async create(createUserDto: CreateUserDto) {
    try {
      if (createUserDto.password !== createUserDto.confirmPassword) {
        throw new BadRequestException('비밀번호가 일치하지 않습니다.');
      }
      
      if (createUserDto.password.length > 20) {
        throw new BadRequestException('비밀번호는 20자 이하로 입력해주세요.');
      }
      
      const passwordPattern =
        /^(?=(.*[a-zA-Z]){2,})(?=(.*\d){2,})(?=(.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]){2,}).{8,}$/;
        
      if (!passwordPattern.test(createUserDto.password)) {
        throw new BadRequestException(
          '비밀번호는 8자 이상, 영문, 숫자, 특수문자를 2종류 이상 조합해주세요.',
        );
      }
    } catch (error) {
      throw error;
    }
  }

 

 

[TDD] Test Case #4; password pattern is valid - 성공 케이스

테스트가 검증이 제대로 이루어졌는지 테스트 하기 위해서 일부러 성공 케이스를 넣어주었다.

 

    it('Case #4: password pattern is valid', async () => {
      // given
      const newUser: CreateUserDto = {
        name: 'test user',
        nickname: 'test',
        email: 'test@test.com',
        userId: 'testId',
        password: '1q2w3e4r1!@',
        confirmPassword: '1q2w3e4r1!@',
      };

      // then
      await expect(() => userService.create(newUser)).not.toThrow();
    });

 

테스트가 제대로 검증이 됐다면, not.toThrow()를 통해서 에러로 던져지지 않음을 알 수 있다.

정규표현식에 의해서 패스워드를 제대로 검증하고 있기 대문에 not.toThrow()는 제대로 검증이 되기 때문에 GREEN 단계는 존재하지 않는다.

 

[TDD] Test Case #5; nickname length is less than 20

닉네임 문자열의 길이는 20자 이하여야 한다

 

    it('Case #5: nickname length is less than 20', async () => {
      // given
      const newUser: CreateUserDto = {
        name: 'test user',
        nickname: 'testnicknametestnicknametestnicknametestnickname',
        email: 'test@test.com',
        password: '1q2w3e4r1!@',
        confirmPassword: '1q2w3e4r1!@',
        userId: 'testId',
      };

      // then
      await expect(userService.create(newUser)).rejects.toThrow(
        '닉네임은 20자 이하로 입력해주세요.',
      );
    });

 

 

이제 서비스 코드에서 닉네임의 글자를 검사해주는 로직을 추가해주면 된다

 

  async create(createUserDto: CreateUserDto) {
    try {
      if (createUserDto.password !== createUserDto.confirmPassword) {
        throw new BadRequestException('비밀번호가 일치하지 않습니다.');
      }
      
      if (createUserDto.password.length > 20) {
        throw new BadRequestException('비밀번호는 20자 이하로 입력해주세요.');
      }
      
      const passwordPattern =
        /^(?=(.*[a-zA-Z]){2,})(?=(.*\d){2,})(?=(.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]){2,}).{8,}$/;
        
      if (!passwordPattern.test(createUserDto.password)) {
        throw new BadRequestException(
          '비밀번호는 8자 이상, 영문, 숫자, 특수문자를 2종류 이상 조합해주세요.',
        );
      }
      
      if (createUserDto.nickname.length > 20) {
        throw new BadRequestException('닉네임은 20자 이하로 입력해주세요.');
      }
    } catch (error) {
      throw error;
    }
  }

 

 

[TDD] Test Case #6; email pattern is invalid

이메일 패턴을 검사해주는 테스트 코드와 서비스 코드를 추가해주면 된다. 사실 이메일 패턴은 DTO에서 검사를 해줄 수 있지만, DTO에서 validation 기능을 추가해주면 그때 다시 수정을 해주면 되기 때문에 일단은 백엔드 로직에 충실하여 검사를 진행해준다

 

    it('Case #6: email pattern is invalid', async () => {
      // given
      const newUser: CreateUserDto = {
        name: 'test user',
        nickname: 'test',
        email: 'emailTest',
        userId: 'testId',
        password: '1q2w3e4r1!@',
        confirmPassword: '1q2w3e4r1!@',
      };

      // then
      await expect(userService.create(newUser)).rejects.toThrow(
        '이메일 형식이 올바르지 않습니다.',
      );
    });

 

패스워드 검사와 마찬가지로 이메일 패턴 검사 서비스 코드도 정규표현식으로 검사를 해준다

 

  async create(createUserDto: CreateUserDto) {
    try {
      if (createUserDto.password !== createUserDto.confirmPassword) {
        throw new BadRequestException('비밀번호가 일치하지 않습니다.');
      }
      
      if (createUserDto.password.length > 20) {
        throw new BadRequestException('비밀번호는 20자 이하로 입력해주세요.');
      }
      
      const passwordPattern =
        /^(?=(.*[a-zA-Z]){2,})(?=(.*\d){2,})(?=(.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]){2,}).{8,}$/;
        
      if (!passwordPattern.test(createUserDto.password)) {
        throw new BadRequestException(
          '비밀번호는 8자 이상, 영문, 숫자, 특수문자를 2종류 이상 조합해주세요.',
        );
      }
      
      if (createUserDto.nickname.length > 20) {
        throw new BadRequestException('닉네임은 20자 이하로 입력해주세요.');
      }
      
      const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
      
      if (!emailPattern.test(createUserDto.email)) {
        throw new BadRequestException('이메일 형식이 올바르지 않습니다.');
      }
    } catch (error) {
      throw error;
    }
  }

 

 

[TDD] Test Case #7; name length is less than 10

실명의 이름은 10자 이하여야 한다. 외국인 출신의 회원이 생길 수 있다는 걸 감안하여 varchar(10)으로 해주었다.

 

    it('Case #7: name length is less than 10', async () => {
      // given
      const newUser: CreateUserDto = {
        name: 'testusername',
        nickname: 'testnickname',
        email: 'test@test.com',
        password: '1q2w3e4r1!@',
        confirmPassword: '1q2w3e4r1!@',
        userId: 'testId',
      };

      // then
      await expect(userService.create(newUser)).rejects.toThrow(
        '이름은 10자 이하로 입력해주세요.',
      );
    });

 

 

위 서비스 코드와 비슷하게 수정을 해주면 된다.

 

  async create(createUserDto: CreateUserDto) {
    try {
      if (createUserDto.password !== createUserDto.confirmPassword) {
        throw new BadRequestException('비밀번호가 일치하지 않습니다.');
      }
      
      if (createUserDto.password.length > 20) {
        throw new BadRequestException('비밀번호는 20자 이하로 입력해주세요.');
      }
      
      const passwordPattern =
        /^(?=(.*[a-zA-Z]){2,})(?=(.*\d){2,})(?=(.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]){2,}).{8,}$/;
        
      if (!passwordPattern.test(createUserDto.password)) {
        throw new BadRequestException(
          '비밀번호는 8자 이상, 영문, 숫자, 특수문자를 2종류 이상 조합해주세요.',
        );
      }
      
      if (createUserDto.nickname.length > 20) {
        throw new BadRequestException('닉네임은 20자 이하로 입력해주세요.');
      }
      
      const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
      
      if (!emailPattern.test(createUserDto.email)) {
        throw new BadRequestException('이메일 형식이 올바르지 않습니다.');
      }
      
      if (createUserDto.name.length > 10) {
        throw new BadRequestException('이름은 10자 이하로 입력해주세요.');
      }
    } catch (error) {
      throw error;
    }
  }

 

 

[TDD] Test Case #8; password bcrypt test

패스워드는 데이터베이스에 저장하기 전에 암호화를 진행 후에 저장이 되어야 하기 때문에 암호화를 해주어야 한다. 그리고 node.js 개발자들이 많이 사용하고 있는 bcrypt 해싱 알고리즘을 사용하려고 한다.

 

bcrypt는 외부 모듈이기 때문에 무조건 모킹을 해주어야 한다.

첫 번째 이유로는 유닛 테스트 단계 때는 외부 모듈에 의해서 테스트가 종속성을 가지면 안되기 때문이고

두 번째 이유로는 모킹을 해주지 않으면 유닛테스트에 맞지 않게 실제 함수가 실행이 되기 때문에 우선적으로 모킹을 해주어야 한다. 

 

bcrypt를 모킹을 해주면 서비스 코드를 수정하지 않은 RED 단계에서도 테스트가 성공이 될 수 있다.

처음에는 이렇게 외부 모듈을 모킹을 해주면 도대체 무엇을 검사하는 건가 싶었지만, 결국 통합 테스트 때 로직 검사를 하기 때문에 문제 없이 지나가도 된다. 

유닛 테스트에서는 개발자가 작성한 코드 대로 기능이 제대로 호출이 되고 이루어지는가를 검증하는 단계이기 때문에, 유닛 테스트 단계에서는 bcrypt가 제대로 호출이 된 것으로 로직상으로 문제가 없다고 판단하기 때문이다.

 

import * as bcrypt from 'bcrypt';
jest.mock('bcrypt');
    
    it('Case #8: password bcrypt test', async () => {
      // given
      const newUser: CreateUserDto = {
        name: 'test user',
        nickname: 'test',
        email: 'test@test.com',
        userId: 'testId',
        password: '1q2w3e4r1!@',
        confirmPassword: '1q2w3e4r1!@',
      };

      const saltOrRounds = 10;
      const hashedPassword = 'mocking_hashed_password';

      (bcrypt.hash as jest.Mock).mockResolvedValue(hashedPassword);

      // when
      const hash = await bcrypt.hash(newUser.password, saltOrRounds);

      // then
      expect(bcrypt.hash).toHaveBeenCalledWith(newUser.password, saltOrRounds);
      expect(hash).toBe(hashedPassword);
    });

 

 

테스트 코드를 보면 toHaveBeenCalledWith() 함수가 있다. 이 함수를 통해서 제대로 호출을 했는가를 테스트함으로써 로직상 문제가 없다고 판단할 수 있다

 

이제 서비스 코드를 수정해주면 된다.

 

  async create(createUserDto: CreateUserDto) {
    try {
      if (createUserDto.password !== createUserDto.confirmPassword) {
        throw new BadRequestException('비밀번호가 일치하지 않습니다.');
      }
      
      if (createUserDto.password.length > 20) {
        throw new BadRequestException('비밀번호는 20자 이하로 입력해주세요.');
      }
      
      const passwordPattern =
        /^(?=(.*[a-zA-Z]){2,})(?=(.*\d){2,})(?=(.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]){2,}).{8,}$/;
        
      if (!passwordPattern.test(createUserDto.password)) {
        throw new BadRequestException(
          '비밀번호는 8자 이상, 영문, 숫자, 특수문자를 2종류 이상 조합해주세요.',
        );
      }
      
      if (createUserDto.nickname.length > 20) {
        throw new BadRequestException('닉네임은 20자 이하로 입력해주세요.');
      }
      
      const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
      
      if (!emailPattern.test(createUserDto.email)) {
        throw new BadRequestException('이메일 형식이 올바르지 않습니다.');
      }
      
      if (createUserDto.name.length > 10) {
        throw new BadRequestException('이름은 10자 이하로 입력해주세요.');
      }
      
      const saltOrRounds = 10;
      const hash = await bcrypt.hash(createUserDto.password, saltOrRounds);
    } catch (error) {
      throw error;
    }
  }

 

제대로 GREEN 단계를 마칠 수 있다.

 

[TDD] Test Case #9; email is already exist

이제 데이터베이스 접근을 통해서 이미 존재하는 이메일인지 판단을 해주어야 한다

이메일이 있다고 가정하여 실패하는 과정을 검증한다.

 

데이터베이스 접근 객체(DAO)도 또한 외부 모듈이기 때문에 모킹을 해주어야 한다.

 

import { UserDao } from '../user.dao';

describe("userService", () => {
  let userService: UserService;
  let userDaoMock: any;
  
  beforeEach(async () => {
    // userDao mocking 부분
    userDaoMock = {
      findByEmail: jest.fn(),
      findByNickname: jest.fn(),
      findByUserId: jest.fn(),
      findOne: jest.fn(),
      findAll: jest.fn(),
      create: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserService,
        {
          provide: UserDao,
          useValue: userDaoMock, // mocking 한 userDao를 useValue에 등록
        },
      ],
    }).compile();

    userService = module.get<UserService>(UserService);
  });
});

 

userDao를 모킹해주었기 때문에 이제 userDaoMock을 사용할 테스트 코드를 작성해주면 된다

 

    it('Case #9: email is already exist', async () => {
      // given
      const newUser: CreateUserDto = {
        name: 'test user',
        nickname: 'test',
        email: 'test@test.com',
        userId: 'testId',
        password: '1q2w3e4r1!@',
        confirmPassword: '1q2w3e4r1!@',
      };

      // when
      userDaoMock.findByEmail.mockResolvedValueOnce({
        email: 'test@test.com',
      });

      // then
      await expect(userService.create(newUser)).rejects.toThrow(
        '이미 존재하는 이메일입니다.',
      );
      expect(userDaoMock.findByEmail).toHaveBeenCalledWith('test@test.com');
    });

 

 

이제 GREEN 단계를 위해서 서비스 코드도 수정을 해준다

 

  async create(createUserDto: CreateUserDto) {
    try {
      if (createUserDto.password !== createUserDto.confirmPassword) {
        throw new BadRequestException('비밀번호가 일치하지 않습니다.');
      }
      
      if (createUserDto.password.length > 20) {
        throw new BadRequestException('비밀번호는 20자 이하로 입력해주세요.');
      }
      
      const passwordPattern =
        /^(?=(.*[a-zA-Z]){2,})(?=(.*\d){2,})(?=(.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]){2,}).{8,}$/;
        
      if (!passwordPattern.test(createUserDto.password)) {
        throw new BadRequestException(
          '비밀번호는 8자 이상, 영문, 숫자, 특수문자를 2종류 이상 조합해주세요.',
        );
      }
      
      if (createUserDto.nickname.length > 20) {
        throw new BadRequestException('닉네임은 20자 이하로 입력해주세요.');
      }
      
      const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
      
      if (!emailPattern.test(createUserDto.email)) {
        throw new BadRequestException('이메일 형식이 올바르지 않습니다.');
      }
      
      if (createUserDto.name.length > 10) {
        throw new BadRequestException('이름은 10자 이하로 입력해주세요.');
      }
      
      const saltOrRounds = 10;
      const hash = await bcrypt.hash(createUserDto.password, saltOrRounds);
      
      const existingUser = await this.userDao.findByEmail(createUserDto.email);

      if (existingUser) {
        throw new BadRequestException('이미 존재하는 이메일입니다.');
      }
    } catch (error) {
      throw error;
    }
  }

 

이제 드디어 하나의 API가 끝나간다.. 사실 테스트를 몇 개 더 해야되지만, 조금 귀찮다.....

 

[TDD] Test Case #10; userId is already exist

이미 존재하는 아이디가 있을 때도 마찬가지로 에러 핸들링을 해주어야 한다

 

    it('Case #11: userId is already exist', async () => {
      // given
      const newUser: CreateUserDto = {
        name: 'test user',
        nickname: 'test',
        email: 'test@test.com',
        userId: 'testId',
        password: '1q2w3e4r1!@',
        confirmPassword: '1q2w3e4r1!@',
      };

      // when
      userDaoMock.findByUserId.mockResolvedValueOnce({
        userId: 'testId',
      });

      // then
      await expect(userService.create(newUser)).rejects.toThrow(
        '이미 존재하는 아이디입니다.',
      );
    });

 

  async create(createUserDto: CreateUserDto) {
    try {
      if (createUserDto.password !== createUserDto.confirmPassword) {
        throw new BadRequestException('비밀번호가 일치하지 않습니다.');
      }
      
      if (createUserDto.password.length > 20) {
        throw new BadRequestException('비밀번호는 20자 이하로 입력해주세요.');
      }
      
      const passwordPattern =
        /^(?=(.*[a-zA-Z]){2,})(?=(.*\d){2,})(?=(.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]){2,}).{8,}$/;
        
      if (!passwordPattern.test(createUserDto.password)) {
        throw new BadRequestException(
          '비밀번호는 8자 이상, 영문, 숫자, 특수문자를 2종류 이상 조합해주세요.',
        );
      }
      
      if (createUserDto.nickname.length > 20) {
        throw new BadRequestException('닉네임은 20자 이하로 입력해주세요.');
      }
      
      const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
      
      if (!emailPattern.test(createUserDto.email)) {
        throw new BadRequestException('이메일 형식이 올바르지 않습니다.');
      }
      
      if (createUserDto.name.length > 10) {
        throw new BadRequestException('이름은 10자 이하로 입력해주세요.');
      }
      
      const saltOrRounds = 10;
      const hash = await bcrypt.hash(createUserDto.password, saltOrRounds);
      
      const existingUser = await this.userDao.findByEmail(createUserDto.email);

      if (existingUser) {
        throw new BadRequestException('이미 존재하는 이메일입니다.');
      }
      
      const existingUserId = await this.userDao.findByUserId(
        createUserDto.userId,
      );

      if (existingUserId) {
        throw new BadRequestException('이미 존재하는 아이디입니다.');
      }
      
      const newUser = await this.userDao.create(
        {
          ...createUserDto,
          password: hash,
        },
        tag,
      );

      return newUser;
    } catch (error) {
      throw error;
    }
  }

 

 

테스트를 진행한 내용은 이것보다 몇 개 더 구현해놨지만... 작성하는게 너무 힘들다..

이것으로 User 모듈 관련 API는 더이상 업로드하지 않을 것이고, User 모듈을 완성하고 나면 아마 Authorization 부분을 업로드 할 것 같다

반응형

'Side project' 카테고리의 다른 글

[Side project] 축구 동호회 공식 사이트 만들기  (3) 2024.09.18