[단위 테스트] 데이터베이스 테스트
통합 테스트에서 중요한 의존성으로 취급되는 데이터베이스를 효과적으로 테스트하는 방법을 알아본다.
통합 테스트를 위한 데이터 베이스 준비
- 개발을 위한 데이터 스키마 관리
데이터베이스 스키마는 버전별로 항상 형상관리에 저장되어야 한다.
- 데이터베이스 배포 방식
상태 기반 데이터 베이스 배포 방식 vs 마이그레이션 기반 데이터 베이스 배포 방식
상태 기반 방식
개발 내내 관리 되는 모델 데이터베이스가 존재. 배포 중 비교 도구가 스키마 차이를 인식하여 차이에 대한 업데이트를 운영 데이터베이스에 적용하기 위한 스크립트를 만들어낸다. 비교 도구는 불필요한 테이블을 삭제하고 새 테이블을 생성하고 컬럼명을 바꾸는 등 모델 데이터베이스와 동기화하는데 필요한 작업을 수행한다.
마이그레이션 기반 방식
데이터베이스 기존 버전에서 새로운 버전으로 업데이트 할 때 필요한 작업을 '마이그레이션'이라는 명시적인 명령 형태로 실행하는 것. 비교 도구가 자동으로 만들어내는 업데이트 스크립트와 달리 직접 작성해야한다.
마이그레이션의 예
CREATE TABLE dbo.Customer... // Customer 테이블 생성
ALTER TABLE dbo.Customer... // Customer 테이블 변경 (컬럼 추가 혹은 변경)
CREATE TABLE dbo.User... // User 테이블 생성
마이그레이션 클래스 예시 - 명령 패턴
class CreateUserTable : public Migration {
public:
virtual void Up() override {
DDL::CreateTable("UserTbl");
}
virtual void Down() override {
DDL::DropTable("UserTbl");
}
};
형상관리에서 관리되는 것은 해당 버전에서의 마이그레이션 명령 집합이다.
상태 기반 보다는 마이그레이션 기반을 선호하라
-> 상태를 형상 관리에 저장함으로써 상태를 저장하고 비교 도구가 마이그레이션을 수행한다.
-> 마이그레이션 기반은 마이그레이션을 명시적으로 수행하지만 상태는 마이그레이션 조합으로 표현된다.
데이터 모션 (변경 내용 문제) : 유연한 변경 내용 설계
Name 컬럼을 -> FisrtName + LastName의 두 컬럼으로 분리해야할 때, 상태 기반으로는 변경을 구현하기 쉽지 않다.
마이그레이션 기반으로는 해당 변경 대한 명령을 구현하여 적용하면 된다.
데이터베이스 트랜잭션 관리
CRM 예제
void UserController::ChangeEmailV4(int userId, string newEmail)
{
auto data = _database->GetUserById(userId);
string email = std::get<0>(data);
UserType type = (UserType)(std::get<1>(data));
User user = User::CreateUser(userId, email, type);
auto companyData = _database->GetCompany();
string companyDomainName = std::get<0>(companyData);
int numberOfEmployees = std::get<1>(companyData);
Company company = Company::CreateCompany(companyDomainName, numberOfEmployees);
// Fast Failure Principle
user.ChangeEmailV4(newEmail, company, _messageBus);
_database->SaveCompany(company);
_database->SaveUser(user);
_eventDispatcher->Dispatch(user._domainEvents);
}
- 기존 데이터베이스에서 고객 정보와 회사 정보를 가져오는 읽기가 먼저 수행된다.
고객 객체로부터 이메일 변경 로직이 정상적으로 수행된 이후 이메일 주소 변경은 해당 고객 정보 변경과 회사 정보 변경에 대한 쓰기가 트랜잭션으로 처리되어야 한다.
데이터베이스 트랜잭션 처리와 데이터 접근/수정에 대한 책임 분리
Repository 클래스
각 도메인에 대한 접근과 수정 연산을 수행하는 Repository 클래스를 도입한다.
Repository 클래스는 접근/수정에 대한 연산을 수행하기 위해 DB 커넥션(트랜잭션)을 내부에 가지고 있어야한다.
Transaction 클래스
모든 변경에 대한 연산이 트랜잭션으로 수행되어야함을 보장하는 클래스이다. 오류가 발생시 중도까지 발생한 연산에 대한 롤백을 수행하고 정상적인 경우 커밋을 통해 데이터베이스에 변경 사항을 반영한다.
Repository 클래스와 마찬가지로 커밋/롤백을 수행하기 위해 내부에 DB 커넥션을 가지고 있다.
CRM 예제
User 도메인을 담당하는 UserRepository 그리고 Company를 담당하는 CompanyRepository를 도입한다.
각 Repository는 DB 커넥션을 들고 있는 Transaction 객체를 받는다.
class Repository
{
private:
Transaction* _transaction;
public:
Repository(Transaction* transaction);
};
class UserRepository : public Repository
{
private:
map<int, DataType> userData;
public:
UserRepository(Transaction* transaction);
void SaveUser(User& user);
DataType GetUserById(int userId) { return userData[userId]; }
};
class CompanyRepository : public Repository
{
private:
DataType companyData;
public:
CompanyRepository(Transaction* transaction);
void SaveCompany(Company& company);
DataType GetCompany() { return companyData; }
};
- 트랜잭션 객체는 커밋/롤백에 대한 책임을 맡는다.
class TransactionGuard {
public:
TransactionGuard(Transaction* transaction);
~TransactionGuard();
void Commit();
private:
bool _committed;
Transaction* _transaction;
};
class DBConnection;
class Transaction
{
private:
DBConnection* _conn;
public:
Transaction(DBConnection* conn);
TransactionGuard Start();
DBConnection* GetConnection();
void Commit();
void Rollback();
};
- TransactionGuard는 RAII 방식으로 예외가 발생했을 시 커밋되지 않은 명령들이 자동으로 롤백처리 되게 한다.
UserController::UserController(Transaction* transaction, IMessageBus* messageBus, EventDispatcher* eventDispatcher)
:
_transaction(transaction),
_messageBus(messageBus),
_eventDispatcher(eventDispatcher)
{
_userRepository = new UserRepository(transaction);
_companyRepository = new CompanyRepository(transaction);
}
void UserController::ChangeEmailV5(int userId, string newEmail)
{
auto data = _userRepository->GetUserById(userId);
string email = std::get<0>(data);
UserType type = (UserType)(std::get<1>(data));
User user = User::CreateUser(userId, email, type);
auto companyData = _companyRepository->GetCompany();
string companyDomainName = std::get<0>(companyData);
int numberOfEmployees = std::get<1>(companyData);
Company company = Company::CreateCompany(companyDomainName, numberOfEmployees);
user.ChangeEmailV4(newEmail, company, _messageBus);
// Sync to DB
auto currentTransaction = _transaction->Start();
_companyRepository->SaveCompany(company);
_userRepository->SaveUser(user);
currentTransaction.Commit();
_eventDispatcher->Dispatch(user._domainEvents);
}
- CRM 예제에서 이메일 주소 변경 로직은 도메인 연산과 트랜잭션 연산이 분리되어 처리된다.
ORM 프레임워크
Repository를 적극적으로 사용하는 ORM 프레임워크에서 도메인 객체는 DB 데이터를 투영하는 객체로 취급된다.
EntityManager라는 클래스는 Entity라고 하는 도메인 객체를 DB와 동기화가 맞도록 관리한다.
ORM 프레임워크는 Enttiy 클래스에 대한 변경 사항을 자동으로 인식하여 쿼리를 생성하고 DB에 해당 쿼리를 요청한다.
void UserController::ChangeEmailORM(int userId, string newEmail)
{
User* user = _userRepository->GetUserById(userId);
Company* company = _companyRepository->GetCompany();
user->ChangeEmail(newEmail, company, _messageBus);
// Sync to DB
auto currentTransaction = _transaction->Start();
_entityManager->Persist(user);
_entityManager->Persist(company);
currentTransaction.Commit();
_eventDispatcher->Dispatch(user->_domainEvents);
}
- 이제 Repository에서 접근된 Entity들은 실제 DB 정보를 투영하는 객체로써 사용되므로 따로 팩터리 메서드를 통해 정보를 파싱할 필요가 없다.
- EntityManager를 통해 변경된 Enttiy 객체를 DB에 반영한다. 새로 생긴 변경 결과는 다시 Entity 객체로 투영된다.
- Transaction 클래스를 통해 트랜잭션으로 처리되어야함을 보장한다.
통합 테스트에서 트랜잭션 반영하기
TEST(TestUserControllerWithDatabase, 통합_테스트_이메일_변경_사내도메인에서_외부도메인으로) {
// given
auto db = new Database(string("connection"));
auto conn = db->GetConnection();
auto transaction = conn->CreateTransaction();
auto messageBusMock = new MockMessageBus();
auto eventDispacther = new EventDispatcher();
auto sut = new UserController(transaction, messageBusMock, eventDispacther);
auto insertUser = User::CreateUser("user@mycorp.com", UserType::Employee);
auto insertCompany = Company::CreateCompany("mycorp.com", 1);
auto userRepository = sut->GetUserRepository();
auto companyRepository = sut->GetCompanyRepository();
// create test data
{
auto trGuard = transaction->Start();
userRepository->SaveUser(insertUser);
companyRepository->SaveCompany(insertCompany);
trGuard.Commit();
}
EXPECT_CALL(*messageBusMock, SendEmailChangedMessage(insertUser._userId, "new@gmail.com")).Times(1);
// when
sut->ChangeEmailV5(insertUser._userId, "new@gmail.com");
// then
auto userData = userRepository->GetUserById(insertUser._userId);
User userFromDB = User::CreateUser(insertUser._userId, std::get<0>(userData), (UserType)std::get<1>(userData));
ASSERT_EQ("new@gmail.com", userFromDB._email);
ASSERT_EQ(UserType::Customer, userFromDB._type);
auto companyData = companyRepository->GetCompany();
Company companyFromDB = Company::CreateCompany(std::get<0>(companyData), std::get<1>(companyData));
ASSERT_EQ(0, companyFromDB._numberOfEmployees);
}
- 기존 통합 테스트에서 Repository와 Transaction 클래스가 반영되었다.
테스트 데이터 생명 주기
- 순차적 실행
병렬적 실행은 테스트 데이터에 의해 둘 이상의 테스트간 경합이 발생할 수 있다. 따라서 테스트는 항상 순차적으로 실행한다.
- 데이터 정리
테스트 데이터는 항상 테스트 시작전에 초기화하고 사용한다.
- 테스트 데이터 베이스를 인메모리형으로 변경
Sqlite혹은 메모리 컨테이너 기반 데이터 베이스로 테스트용 데이터베이스를 사용하지 않는 것이 좋다.
RDMBS가 아닌 인메모리 데이터베이스는 실제 운영환경과 일치하지 않기 때문이다.
테스트 픽스처를 이용한 UserController 통합 테스트 만들기
class UserControllerIntegrateTest : public ::testing::Test
{
protected:
Database* db;
DBConnection* conn;
Transaction* transaction;
MockMessageBus* messageBusMock;
EventDispatcher* eventDispacther;
UserController* sut;
public:
UserControllerIntegrateTest()
:
::testing::Test() {
db = new Database(string("connection"));
conn = db->GetConnection();
transaction = conn->CreateTransaction();
messageBusMock = new MockMessageBus();
eventDispacther = new EventDispatcher();
sut = new UserController(transaction, messageBusMock, eventDispacther);
}
virtual void SetUp() override {
ClearDatabase();
}
virtual void TearDown() override {
conn->Close();
}
// ...
private:
void ClearDatabase() {
string query = "DELETE FROM dbo.[User];"\
"DELETE FROM dbo.Company;";
conn->ExecuteNonQuery(query);
}
};
테스트 픽스처를 사용하면 각 케이스에서 필요한 테스트 데이터 초기화를 쉽게 구현할 수 있다. (SetUp에서 ClearDatabase를 수행한다.)
코드 재사용하기
Repository에서 도메인 객체를 가져오는 것을 비공개 함수화 하여 여러 테스트에서 재사용할 수 있게 한다.
void CreateUser(User& user) {
sut->GetUserRepository()->SaveUser(user);
transaction->Commit();
}
void CreateCompany(Company& company) {
sut->GetCompanyRepository()->SaveCompany(company);
transaction->Commit();
}
최종 검증 구절에서 읽기로 도메인 객체를 가져오는 것도 함수화 하여 재사용 가능하다.
User SelectUser(int userId) {
auto userData = sut->GetUserRepository()->GetUserById(userId);
return User::CreateUser(userId, std::get<0>(userData), (UserType)std::get<1>(userData));
}
Company SelectCompany() {
auto companyData = sut->GetCompanyRepository()->GetCompany();
return Company::CreateCompany(std::get<0>(companyData), std::get<1>(companyData));
}
실행 구절 부분 재사용하기
실행 구절은 1줄 내로 실행되어야한다. UserController에 대한 통합 테스트는 해당 멤버 함수를 보통 호출하고 결과를 확인하므로 다음과 같이 함수를 일반화하여 재사용 가능하다.
template<typename R, typename ...FArgs, typename ...Args>
R Execute(R(UserController::* memFunc)(FArgs...), Args&&... args) {
return std::invoke(memFunc, sut, std::forward<Args>(args)...);
}
완성되어 정리된 테스트 구절
TEST_F(UserControllerIntegrateTest, 통합_테스트_이메일_변경_사내도메인에서_외부도메인으로) {
// given
auto insertUser = User::CreateUser("user@mycorp.com", UserType::Employee);
auto insertCompany = Company::CreateCompany("mycorp.com", 1);
// create test data
CreateUser(insertUser);
CreateCompany(insertCompany);
EXPECT_CALL(*messageBusMock, SendEmailChangedMessage(insertUser._userId, "new@gmail.com")).Times(1);
// when
Execute(&UserController::ChangeEmailV5, insertUser._userId, "new@gmail.com");
// then
User userFromDB = SelectUser(insertUser._userId);
ASSERT_EQ("new@gmail.com", userFromDB._email);
ASSERT_EQ(UserType::Customer, userFromDB._type);
Company companyFromDB = SelectCompany();
ASSERT_EQ(0, companyFromDB._numberOfEmployees);
}
추가 고민
Repository 클래스에 대한 테스트 필요성?
-> Repository는 도메인에 대한 접근/수정을 포함한다.
-> 수정 연산에는 DB 의존성을 통해 수행된다.
Repository 테스트는 필요하지 않다.
해당 로직은 단순하지만 Controller와 마찬가지로 의존성을 지닌다.
코드 유형 다이어그램에서 Repository는 복잡도는 낮지만 의존성은 높은 면에 속한다.
그렇지만 Controller만큼 중요한 로직을 담당하지는 않으며 그에 대한 테스트를 수행했을 때 유지 보수 비용은 있는 편이다.
Repository를 위한 또 다른 테스트를 만들기보다는 ORM 프레임워크를 사용한 경우 라이브러리가 보장하는 정확도를 신뢰하는 편이 낫다.
결론
- 데이터베이스 스키마와 마이그레이션을 형상 관리로 저장하고 마이그레이션 방식을 통한 배포 방식을 선호하라.
- 데이터베이스 테스트를 잘 만들면 Controller 통합 테스트를 훌륭하게 수행할 수 있다.
- 트랜잭션이 필요하다면 해당 책임 클래스를 만들고 Repository 클래스를 통해 접근/수정 및 트랜잭션 처리를 분리한다.
- 데이터 베이스 테스트 데이터를 각 케이스 초기에 초기화하고 테스트 픽스처를 통해 구현할 수 있다.
- 통합 테스트 구절은 재사용 가능하도록 리팩토링한다.
- Repository 클래스를 위한 통합 테스트를 작성하지 말라.
참고
Unit Testing 단위 테스트, 블라디미르 코리코프 지음
https://www.sandordargo.com/blog/2019/04/24/parameterized-testing-with-gtest#parameterizedtests
https://github.com/jinsnowy/UnitTest_Study