[단위 테스트] 단위 테스트 스타일
단위 테스트 스타일
- 출력 기반 테스트
- 상태 기반 테스트
- 통신 기반 테스트
출력 기반 스타일
테스트 대상 시스템 SUT에 입력을 넣고 생성되는 출력을 점검하는 방식
"함수형 프로그래밍"
-> 사이드 이펙트가 없는 방식
상태 기반 스타일
작업이 완료된 이후 상태를 확인하여 검증하는 방식.
SUT, 협력자 혹은 데이터 베이스나 파일 시스템과 같은 외부 의존성 등의 상태를 확인.
통신 기반 스타일
테스트 대상과 협력자간의 통신을 검증한다.
출력 기반 vs 상태 기반 vs 통신 기반
출력 기반이 가장 단순하고 유지 보수하기 쉽다. 구현 세부사항과 결합이 거의 없다.
상태 기반은 거짓 양성이 되기 쉽다. 테스트 코드와 제품 코드의 결합도가 높아지기 때문.
통신 기반 규모가 있고 복잡하여 유지 보수하기 어렵다.
출력 기반 테스트를 선호하라.
함수형 프로그래밍
출력은 오로지 입력값에 의해 결정되는 순수 함수
출력기반 (함수형) 테스트를 만들기 어렵게 하는 요소
- 사이드 이펙트 : 메서드 시그니처에 표시되지 않은 출력
- 예외 : 메서드가 예외를 던지면, 메서드 시그니처에 설정된 계약을 우회하는 경로를 만듦
- 내 외부 상태에 대한 참조 : 숨은 입력으로 취급되는 요소
함수형 아키텍처
사이드 이펙트가 없는 순수 함수형 코어 부분과 해당 결과에 따라 작용하는 가변 셸 부분으로 나뉨.
- 가변 셸 : 모든 입력을 수집
- 함수형 코어 : 모든 입력으로 부터 결정을 생성
- 셸 : 결정을 사이드 이펙트로 변환
육각형 아키텍처와 비교
도메인 : 함수형 코어로 치환될 수 있는 부분
어플리케이션 서비스 : 사이드 이펙트를 발생. 비즈니스 가장자리 부분.
함수형 아키텍처 및 출력 기반 테스트 설계
감사 시스템 AuditManager
- 방문자 이름과 방문 시간을 받아 파일에 기록하는 시스템
void AuditManager::AddRecord(string visitorName, string timeOfVisit) {
vector<string> filePaths = _fileSystem.GetFiles(_directoryName);
sort(filePaths.begin(), filePaths.end());
string newRecord = format("{};{}", visitorName.c_str(), timeOfVisit.c_str());
if (filePaths.size() == 0) {
string newFilePath = GetFilePath("audit_1.txt");
WriteToFile(newFilePath, newRecord);
return;
}
string lastFilePath = filePaths.back();
vector<string> lines = ReadLines(lastFilePath);
if (lines.size() < _maxEntriesPerFile) {
WriteToFile(lastFilePath, newRecord);
}
else {
int newIndex = (int)filePaths.size() + 1;
string newFileName = format("audit_{}.txt", newIndex);
string newFilePath = GetFilePath(newFileName);
WriteToFile(newFilePath, newRecord);
}
}
- ReadLines : 파일로 부터 모든 정보를 읽어드림.
- WriteToFile : 파일에 새로운 정보를 쓰기함.
테스트를 위한 준비사항
- 읽어드릴 데이터 파일 : 어떤 상태의 파일을 준비할 것인가
- 감사 시스템 : 데이터 파일을 읽고 쓰는 행위
- 테스트 : 각 케이스의 결과로 부터 데이터 파일 상태를 검증
파일 입출력은 테스트를 느리게 함. 병렬 처리가 불가.
파일 시스템을 목으로 대체하여 테스트
-> 입출력으로 인한 병목 제거
-> 테스트 대상 시스템 SUT로 부터 공유 의존성 (파일 시스템) 제거
TEST(TestAuditManager, 기존_파일_없을때_AUDIT_1_TXT_파일에_레코드_생성) {
// given
MockFileSystem fileSystem;
AuditManager sut(fileSystem, 3, ".");
ON_CALL(fileSystem, GetFiles("."))
.WillByDefault(Return(vector<string>{}));
EXPECT_CALL(fileSystem, WriteLine("./audit_1.txt", "Alice;2019-04-06T18:00:00"))
.Times(1);
// when
sut.AddRecord("Alice", "2019-04-06T18:00:00");
}
함수형 아키텍처로 감사 시스템(AuditManager)를 다시 설계
기존 시스템에서 감사 시스템 내부에 파일 시스템 의존성을 갖는 것을 제거.
감사 시스템을 함수형 코어로 / 파일 시스템(Persister)은 가변 셸로 설계
- 파일 시스템은 감사 시스템이 메서드를 수행하기 위해 필요한 모든 입력 정보를 전달
- 감사 시스템은 해당 입력 정보를 바탕으로 파일 시스템이 처리하기 위해 필요한 정보를 반환
Persister : 가변 셸
vector<FileContent> Persister::ReadDirectory(string directoryName)
{
vector<FileContent> fileContents;
FileSystem fs;
vector<string> files = fs.GetFiles(directoryName);
for (const auto& file : files) {
string fileName = GetFileNameFromPath(file);
vector<string> lines = fs.ReadLines(fileName);
fileContents.emplace_back(fileName, lines);
}
return fileContents;
}
void Persister::ApplyUpdate(string directoryName, FileUpdate update)
{
FileSystem fs;
string filePath = format("{}/{}", directoryName.c_str(), update.fileName.c_str());
fs.WriteLine(filePath, update.newContent);
}
AuditManager : 함수형 코어
FileUpdate AuditManager2::AddRecord(vector<FileContent> files, string visitorName, string timeOfVisit)
{
string newRecord = format("{};{}", visitorName.c_str(), timeOfVisit.c_str());
if (files.empty()) {
return FileUpdate{"audit_1.txt", newRecord};
}
sort(files.begin(), files.end());
auto& lastFileContent = files.back();
if (lastFileContent.lines.size() < _maxEntriesPerFile) {
return FileUpdate{ lastFileContent.fileName, newRecord };
}
else {
int nextIndex = (int)files.size() + 1;
string fileName = format("audit_{}.txt", nextIndex);
return FileUpdate{ fileName, newRecord };
}
}
테스트 예시
TEST(TestAuditManager, 기존_파일_FULL_일때_새로운_파일에_레코드_생성2) {
// given
string dirName = ".";
AuditManager2 sut = AuditManager2(3);
vector<FileContent> contents = {
FileContent("audit_1.txt", {}),
FileContent("audit_2.txt", {
"Peter;2019-04-06T16:30:30",
"Jane;2019-11-11T09:30:00",
"Jack;2019-08-30T02:00:00"
})
};
// when
FileUpdate update = sut.AddRecord(contents, "Alice", "2019-04-06T18:00:00");
// then
ASSERT_EQ(update.fileName, "audit_3.txt");
ASSERT_EQ(update.newContent, "Alice;2019-04-06T18:00:00");
}
고려사항
- 성능 저하 -> 함수형 아키텍처로 설계했을 때 외부 호출이 많아지므로 성능상 감소가 있을 수 있음.
- 코드 베이스 증가 -> 초기 단계에서 함수형 아키텍처로의 설계가 필요함, 시간이 걸릴 수 있음.
결론
- 출력 기반 테스트가 품질이 가장 좋음.
- 함수형 프로그래밍은 비지니스 로직과 사이드 이펙트를 분리하는 것.
- 함수형 아키텍처에서 가변 셸은 입력 수집과 출력 처리(사이드 이펙트) 그리고 함수형 코어 부분은 비지니스 로직을 담당하여 사이드 이펙트가 없는 연산을 처리함.
- 함수형 아키텍처와 전통적 아키텍처의 선택은 성능과 코드 크기에 따른 절충안을 선택해야함.
참고
Unit Testing 단위 테스트, 블라디미르 코리코프 지음
https://www.sandordargo.com/blog/2019/04/24/parameterized-testing-with-gtest#parameterizedtests
https://github.com/jinsnowy/UnitTest_Study