클린 소프트웨어 - 테스트

테스트

하나의 단위 테스트를 작성하는 것은 단순한 검증이라기보다는 설계의 문제며, 문서화의 문제다.

테스트 주도 개발

실제 코드를 작성하기 전 실패하는 테스트를 만든 다음에야 프로그램에 그 코드를 추가하는 것으로 얻는 이점은 무엇이 있을까?

프로그램의 모든 단일 함수가 그 동작을 검증하는 테스트를 갖게 됨

개발자가 이후에 어떤 기능을 망가뜨릴 때마다 그 사실을 알려주므로 프로그램에 함수를 추가하거나 구조를 바꾸는 것을 마음 편하게 할 수 있다. 테스트는 프로그램이 제대로 작동한다는 것을 뒷받침해주므로, 개발자는 훨씬 자유롭게 프로그램을 수정하거나 개선할 수 있다.

개발자가 다른 관점에서 문제를 해결할 수 있음

개발자는 프로그램의 호출자 관점에서 프로그램을 바라볼 수 있게 되며, 이로써 프로그램의 함수 뿐만 아니라 인터페이스에도 관심을 갖게 된다. 이를 통해 개발자는 편리하게 호출할 수 있는 소프트웨어를 설계할 수 있다.

반드시 테스트 가능한 프로그램을 설계하도록 강제함

테스트 가능한 프로그램을 설계한다는 것은 프로그램이 주위 환경에서 분리되어야 한다는 것을 의미한다. 그러므로 테스트를 먼저 작성하여 개발자가 소프트웨어를 다른 환경과 분리하도록 강제할 수 있다.

문서화의 한 형태로 기능할 수 있음

어떠한 함수를 호출하거나 어떠한 객체를 생성하는 방법을 알고 싶을 때, 이를 보여주는 테스트가 존재하게 된다. 테스트 코드는 다른 개발자가 그 코드를 사용하는 방법을 알게 도와주는 예제의 역할을 할 수 있다.

이 개념의 문서화는 컴파일 및 실행이 가능하고, 항상 최신의 상태를 반영하고, 거짓을 보여줄 수 없다.

테스트 우선 방식 설계의 예

func testMove() {
let game = WumpusGame()
game.connect(from: 4, to: 5, direction: .east)
game.setPlayerRoom(4)
game.move(to: .east)
XCTAssertEqual(5, game.playerRoom)
}

위의 코드는 1. 4번 방과 5번 방을 연결하고 2. 플레이어를 4번 방에 두고 3. 플레이어를 동쪽으로 이동시켜 4. 플레이어가 5번 방에 위치해 있는지를 테스트한다.

이 코드가 실제 코드가 작성되기 이전에 작성되었다면, 이 테스트 코드가 내포하는 구조에 맞는 코드를 작성하기만 하면 이 테스트를 동작하게 할 수 있다고 생각할 수 있다.

이는 ‘계획된 프로그래밍’을 할 수 있다는 것을 의미한다. 개발자는 자신의 의도를 실제로 구현하기 전에 그 의도를 가능한 한 단순하고 읽기 편하게 만들어 테스트로 제시할 수 있으며, 이 단순성과 명쾌함이 프로그램의 좋은 구조를 의미한다고 말할 수 있다.

또한 이 테스트는 방을 ‘연결’한다는 개념으로 접근하였는데, 다른 개발자는 ‘연결’이 아닌 ‘방’의 개념이 중요하다고 말할 수도 있을 것이다. 이처럼 테스트는 서로 간 설계의 접근 방식에 대한 차이를 보여줄 수 있으므로, 테스트를 먼저 작성하여 이른 시점에 설계 의사결정의 차이를 식별할 수 있다.

또한 이 테스트 코드는 매우 직관적이므로 실제 코드도 잘 작성될 수 있을 것이다. 이 기능이 동작하는 방식을 설명하기 위해 테스트 코드를 제시할 수 있으므로 프로그램을 설명하는 컴파일 가능하고 실행 가능한 문서의 역할을 할 수 있다.

테스트 분리

다음의 클래스 구조가 있다고 가정하자.

Payroll 클래스는 CheckWriter / Employee / EmployeeDatabase 클래스와 직접 연관됨
EmployeeDatabase 클래스는 Employee 클래스에 의존

위의 구조에서 Payroll 객체의 행위를 테스트하는 코드를 작성하는 것은 쉽지 않다. Payroll 클래스와 관련된 클래스들이 제대로 동작하고 있는지부터 알아야 하는데, 이는 Payroll 객체만을 테스트하는 것이 아니므로 올바르지 않다.

이를 해결하기 위해 의사 객체mock object 패턴을 사용할 수 있다. Payroll 클래스와 이와 연관된 클래스 사이에 인터페이스를 추가하고 이 인터페이스를 구현하는 테스트 스텁stub을 생성하여 Payroll 객체의 테스트 때 테스트 스텁을 사용하게 할 수 있다.

func testPayroll() {
let mockDatabase = MockEmployeeDatabase()
let mockCheckWriter = MockCheckWriter()
let payroll = Payroll(db: mockDatabase, checkWriter: mockCheckWriter)
payroll.payEmployees()
XCTAssertTrue(mockCheckWriter.checksWereWrittenCorrectly)
XCTAssertTrue(mockDatabase.paymentsWerePostedCorrectly)
}

위의 테스트 코드는 Payroll 객체의 행위를 테스트하지 않는다. 그보다는 Payroll 클래스가 분리된 것처럼 동작하는지 여부를 검사한다.

이로서 Payroll 객체와 이와 연관된 객체들이 분리되었다.

운 좋게 얻은 분리

일단 객체를 다른 객체와 분리하는 것은 바람직한 일이다. 애플리케이션의 확장이라는 측면에서 기존의 데이터베이스와 수표 기록기가 다른 것들로 교체할 수 있게 된다.

흥미로운 것은, 이 분리 작업이 테스트에 대한 필요에서 촉발했다는 점이다. 테스트에서 모듈 분리에 대한 필요성은 개발자가 프로그램 전체 구조에 이득이 되는 방식으로 분리 작업을 하도록 강제한다.

그러므로, 코드보다 테스트를 먼저 작성하는 설계가 개선된다.

의존성을 관리하기 위한 여러 전략을 사용하여 모듈을 분리하고, 이를 단위 테스트를 위한 전략으로도 사용할 수 있다.

인수 테스트

단위 테스트는 시스템의 작은 구성 요소가 기대한 대로 동작하는지 검사하는 반면, 인수 테스트는 시스템 전체가 제대로 동작하는지를 검증한다. 단위 테스트는 모듈의 내부 구조를 알고 테스트하는 화이트박스 테스트이며, 인수 테스트는 모듈의 내부 구조를 모르고 테스트하는 블랙박스 테스트다.

인수 테스트는 시스템의 내부 매커니즘을 모르는 고객이나 QA 팀이 작성할 수 있다. 인수 테스트는 프로그램이므로 실행 가능하다.

인수 테스트는 기능 요소의 궁극적인 문서화 형태다. 인수 테스트에 작성된 것을 보고 개발자는 정확하게 그 기능 요소를 이해할 수 있다. 또한 단위 테스트가 시스템의 내부 요소를 위한 컴파일 및 실행 가능한 문서의 역할을 했다면, 인수 테스트는 시스템의 기능 요소를 위한 컴파일 및 실행 가능한 문서의 역할을 한다.

인수 테스트를 먼저 작성하는 것은 시스템 아키텍처에 많은 영향을 준다. 시스템이 UI를 가지고 있다면, UI 레이어를 거치지 않고도 기능을 테스트할 수 있어야 하는데, 이러한 것들이 인수 테스트를 수행하기 위해 구현될 수 있다.

단위 테스트가 작은 단위에서 뛰어난 설계 의사결정을 할 수 있게 하는 반면, 인수 테스트는 큰 단위에서 뛰어난 아키텍처 의사결정을 할 수 있게 한다.

인수 테스트의 예

코드를 작성하지 않았고 설계에도 시간을 들이지 않았다면 인수 테스트에 대해 생각해볼 만하다. 이를 통해 계획된 프로그래밍을 실현할 수 있다.

AddEmp 11 "Presto" 5000
Payday
Verify Paycheck EmpId 11 GrossPay 5000

위의 인수 테스트 스크립트 중 처음 두 줄은 프로그램의 트랜잭션에 대한 내용이며, 마지막 라인은 인수 테스트에 특화된 지시에 대한 내용이다. 인수 테스트 프레임워크는 트랜잭션과 인수 테스트 지시어를 분리하여 스크립트를 해석해야 한다.

이는 프로그램에 아키텍처와 관련된 긴장을 주게 된다. 프로그램은 사용자로부터도, 인수 테스트 프레임워크로부터도 입력을 직접 받을 수 있어야 하므로, 별도의 트랜잭션 처리기를 만들게 될 것이다.

운 좋게 얻은 아키텍처

인수 테스트를 작성하는 것으로 로직을 프로그램과 분리시키는 아키텍처를 생각할 수 있게 되었다. 아키텍처와 관련된 의사결정을 인수 테스트를 작성하는 것으로 할 수 있다.

결론

테스트를 작성하여 시스템에 문제가 발생한 채로 있는 시간을 최소화할 수 있고, 시스템의 품질을 일정 수준 이상으로 유지할 수 있다.

검증은 테스트 작성이 주는 이점 중 하나일 뿐이다. 문서화의 기능을 수행할 수 있고, 실제 코드를 사용하기 위한 예제의 역할을 할 수 있다.

심지어 아키텍처 및 설계에 중요한 영향을 미치게 된다. 테스트 가능하게 만들기 위해서 주위 환경으로부터 분리시키는 과정에서 소프트웨어의 구조적 품질을 향상시킬 수 있다.