단위 테스트를 작성할 때 Mock을 사용하지 마십시오
게시 됨: 2018-05-02참고: 이것은 수석 엔지니어인 Seth Ammons가 작성한 최신 기술 엔지니어링 게시물입니다. 이 게시물을 검토해 준 Sam Nguyen, Kane Kim, Elmer Thomas 및 Kevin Gillette에게 특별한 감사를 전합니다 . 이와 같은 게시물을 더 보려면 기술 블로그 롤을 확인하십시오.
나는 내 코드, 특히 단위 테스트에 대한 테스트를 작성하는 것을 정말 즐깁니다. 그것이 주는 자신감은 대단합니다. 오랫동안 작업하지 않은 것을 선택하고 단위 및 통합 테스트를 실행할 수 있다는 것은 필요한 경우 무자비하게 리팩토링할 수 있다는 지식을 제공합니다. , 나는 여전히 기능적인 소프트웨어를 나중에 가질 것입니다.
단위 테스트는 코드 설계를 안내하고 실패 모드와 논리 흐름이 의도한 대로 작동하는지 빠르게 확인할 수 있도록 합니다. 그것으로, 나는 아마도 조금 더 논란의 여지가 있는 것에 대해 쓰고 싶습니다. 단위 테스트를 작성할 때 모의를 사용하지 마십시오.
표에 대한 몇 가지 정의를 살펴보겠습니다.
단위 테스트와 통합 테스트의 차이점은 무엇입니까? 모의는 무엇을 의미하며 대신 무엇을 사용해야 합니까? 이 포스트는 바둑에서 작업하는 것에 초점을 맞추고 있으므로 이러한 단어에 대한 나의 경사는 바둑의 맥락에서 입니다.
내가 단위 테스트 라고 하는 것은 적절한 오류 처리를 보장하고 작은 단위의 코드를 테스트하여 시스템 설계를 안내하는 테스트를 말하는 것입니다. 단위로는 전체 패키지, 인터페이스 또는 개별 메서드를 참조할 수 있습니다.
통합 테스트는 실제로 종속 시스템 및/또는 라이브러리와 상호 작용하는 곳입니다. 내가 "모의"라고 말할 때 나는 구체적으로 "모의 객체"라는 용어를 언급하고 있습니다. 여기서 우리는 "도메인 코드를 실제 기능을 에뮬레이트 하고 코드 의 동작에 대한 주장을 시행 하는 더미 구현으로 교체합니다[1]"(강조 나의 것).
조금 더 짧게 설명하면: mock은 다음과 같은 행동을 주장합니다.
MyMock.Method("foo").Called(1).WithArgs("bar").Returns("raz")
나는 "모의보다는 가짜"를 옹호합니다.
가짜는 비즈니스 행동을 포함할 수 있는 일종의 테스트 더블입니다[2]. 가짜는 인터페이스에 맞는 구조체일 뿐이며 동작을 제어하는 종속성 주입의 한 형태입니다. 가짜의 주요 이점은 코드에서 결합이 감소한다는 것입니다. 여기서 모의는 결합을 증가시키고 결합은 리팩토링을 더 어렵게 만듭니다[3].
이 게시물에서 나는 가짜가 유연성을 제공하고 테스트와 리팩토링을 쉽게 할 수 있다는 것을 보여주고자 합니다. 그들은 mock에 비해 의존성을 줄이고 유지하기 쉽습니다.
이러한 성격의 일반적인 게시물에서 볼 수 있는 "합계 함수 테스트"보다 약간 더 발전된 예를 들어 보겠습니다. 그러나 이 게시물에 나오는 코드를 더 쉽게 이해할 수 있도록 약간의 컨텍스트를 제공해야 합니다.
SendGrid에서 우리 시스템 중 하나는 전통적으로 로컬 파일 시스템에 파일을 가지고 있었지만 더 높은 가용성과 더 나은 처리량이 필요하기 때문에 이러한 파일을 S3로 옮기고 있습니다.
이러한 파일을 읽을 수 있어야 하는 응용 프로그램이 있으며 구성에 따라 "로컬" 또는 "원격" 두 가지 모드에서 실행할 수 있는 응용 프로그램을 선택했습니다. 많은 코드 샘플에서 생략된 주의 사항은 원격 오류의 경우 로컬에서 파일을 읽는 것으로 대체한다는 것입니다.
그것을 제외하고 이 애플리케이션에는 패키지 게터가 있습니다. 패키지 getter가 원격 파일 시스템이나 로컬 파일 시스템에서 파일을 가져올 수 있는지 확인해야 합니다.
순진한 접근: 라이브러리 및 시스템 수준 호출만 호출
순진한 접근 방식은 구현 패키지가 getter.New(...) 를 호출하고 원격 또는 로컬 파일 가져오기를 설정하는 데 필요한 정보를 전달하고 Getter 를 반환한다는 것입니다. 그러면 반환된 값은 원격 또는 로컬 파일을 찾는 데 필요한 매개변수를 사용하여 MyGetter.GetFile(...) 을 호출할 수 있습니다.
이것은 우리에게 우리의 기본 구조를 줄 것입니다. 새로운 Getter 를 생성할 때 잠재적인 원격 파일 가져오기(액세스 키 및 비밀)에 필요한 매개변수를 초기화하고 코드에 시도하도록 지시하는 useRemoteFS 와 같은 애플리케이션 구성에서 비롯된 일부 값도 전달합니다. 원격 파일 시스템.
몇 가지 기본 기능을 제공해야 합니다. 여기에서 순진한 코드를 확인하십시오 [4]; 아래는 축소 버전입니다. 이것은 미완성 예제이며 우리는 리팩토링할 것입니다.
여기서 기본 아이디어는 원격 파일 시스템에서 읽도록 구성되어 있고 원격 파일 시스템 세부 정보(호스트, 버킷 및 키)를 얻은 경우 원격 파일 시스템에서 읽기를 시도해야 한다는 것입니다. 시스템이 원격으로 읽는 것을 확신한 후에는 모든 파일 읽기를 원격 파일 시스템으로 이동하고 로컬 파일 시스템에서 읽기에 대한 참조를 제거합니다.
이 코드는 단위 테스트에 그다지 친숙하지 않습니다. 작동 방식을 확인하려면 실제로 로컬 파일 시스템뿐 아니라 원격 파일 시스템도 공격해야 합니다. 이제 통합 테스트를 수행하고 코드에서 행복한 경로를 확인할 수 있는 s3 인스턴스를 갖도록 일부 Docker 마술을 설정할 수 있습니다.
단위 테스트는 대체 코드 및 오류 경로를 쉽게 테스트하여 보다 강력한 소프트웨어를 설계하는 데 도움이 되지만 통합 테스트만 갖는 것은 이상적이지 않습니다. 더 큰 종류의 테스트를 위해 통합 테스트를 저장해야 합니다. 지금은 단위 테스트에 집중합시다.
이 코드를 어떻게 더 단위 테스트 가능하게 만들 수 있습니까? 생각에는 두 가지 학파가 있습니다. 하나는 모의 테스트에 사용할 상용구 코드를 생성하는 모의 생성기(예: https://github.com/vektra/mockery 또는 https://github.com/golang/mock)를 사용하는 것입니다.
이 경로로 이동하여 파일 시스템 호출과 Minio 클라이언트 호출을 생성할 수 있습니다. 또는 종속성을 피하고 싶기 때문에 손으로 모의 객체를 생성할 수도 있습니다. 구체적으로 유형이 지정된 개체를 반환하는 구체적으로 유형이 지정된 클라이언트가 있기 때문에 Minio 클라이언트를 조롱하는 것은 간단하지 않습니다.
조롱하는 것보다 더 나은 방법이 있다고 말합니다. 더 테스트 가능하도록 코드를 재구성하면 모의 및 관련 cruft에 대한 추가 가져오기가 필요하지 않으며 인터페이스를 자신 있게 테스트하기 위해 추가 테스트 DSL을 알 필요가 없습니다. 과도하게 결합되지 않도록 코드를 설정할 수 있으며 테스트 코드는 Go 인터페이스를 사용하는 일반적인 Go 코드일 뿐입니다. 해보자!
인터페이스 접근: 더 큰 추상화, 더 쉬운 테스트
우리가 테스트해야 할 것은 무엇입니까? 여기에서 일부 새로운 Gopher가 잘못 알고 있습니다. 나는 사람들이 인터페이스 활용의 가치를 이해하고 있지만 그들이 사용하는 패키지의 구체적인 구현과 일치하는 인터페이스가 필요하다고 느끼는 것을 보았습니다.
그들은 우리가 Minio 클라이언트를 가지고 있다는 것을 알 수 있으므로 Minio 클라이언트(또는 다른 s3 클라이언트)의 모든 방법 및 용도와 일치하는 인터페이스를 만드는 것으로 시작할 수 있습니다. 그들은 "인터페이스가 클수록 추상화가 약하다"는 바둑 속담[5][6]을 잊는다.
Minio 클라이언트에 대해 테스트할 필요가 없습니다. 파일을 원격 또는 로컬로 가져올 수 있는지 테스트해야 합니다(원격 오류와 같은 일부 오류 경로 확인). 초기 접근 방식을 리팩토링하고 Minio 클라이언트를 원격 getter로 끌어내자. 이 작업을 수행하는 동안 로컬 파일 읽기용 코드에 동일한 작업을 수행하고 로컬 게터를 만들어 보겠습니다. 다음은 기본 인터페이스이며 각각을 구현하는 유형이 있습니다.
이러한 추상화를 통해 초기 구현을 리팩토링할 수 있습니다. localFetcher 와 remoteFetcher 를 Getter 구조체에 넣고 이를 사용하도록 GetFile 을 리팩터링할 것입니다. 리팩토링된 코드의 전체 버전은 여기[7]에서 확인하십시오. 다음은 새 인터페이스 버전을 사용하는 약간 단순화된 스니펫입니다.
리팩토링된 이 새로운 코드는 인터페이스를 Getter 구조체의 매개변수로 사용하고 가짜에 대한 구체적인 유형을 변경할 수 있기 때문에 훨씬 더 단위 테스트가 가능합니다. OS 호출을 조롱하거나 Minio 클라이언트 또는 대형 인터페이스의 전체 조롱을 필요로 하는 대신 두 개의 간단한 가짜( fakeLocalFetcher 및 fakeRemoteFetcher )만 있으면 됩니다.
이러한 가짜에는 반환 대상을 지정할 수 있는 몇 가지 속성이 있습니다. 파일 데이터나 원하는 오류를 반환할 수 있으며 호출하는 GetFile 메서드가 데이터와 오류를 의도한 대로 처리하는지 확인할 수 있습니다.
이를 염두에 두고 테스트의 핵심은 다음과 같습니다.
이 기본 구조를 사용하여 테이블 기반 테스트에서 모두 마무리할 수 있습니다[8]. 테스트 테이블의 각 경우는 로컬 또는 원격 파일 액세스를 테스트합니다. 원격 또는 로컬 파일 액세스에서 오류를 주입할 수 있습니다. 전파된 오류, 파일 내용이 전달되었는지, 예상 로그 항목이 있는지 확인할 수 있습니다.
나는 계속해서 모든 잠재적인 테스트 케이스와 순열을 여기에서 사용할 수 있는 하나의 테이블 기반 테스트에 포함했습니다[9](일부 메소드 서명이 약간 다르다는 것을 알 수 있습니다. 이를 통해 로거를 삽입하고 로그 문에 대해 어설션하는 것과 같은 작업을 수행할 수 있습니다. ).
멋진, 응? 우리는 GetFile 의 작동 방식을 완전히 제어할 수 있으며 결과에 대해 주장할 수 있습니다. 우리는 코드를 단위 테스트에 친숙하게 설계했으며 이제 GetFile 메서드에서 구현된 성공 및 오류 경로를 확인할 수 있습니다.
코드는 느슨하게 결합되어 있으며 향후 리팩토링이 간편해야 합니다. Go에 익숙한 모든 개발자가 필요할 때 이해하고 확장할 수 있는 일반 Go 코드를 작성하여 이를 수행했습니다.
Mocks: 핵심 구현 세부 사항은 어떻습니까?
제안된 솔루션에서 얻을 수 없는 모의가 우리에게 무엇을 사줍니까? 전통적인 모의의 이점을 보여주는 훌륭한 질문은 "올바른 매개변수를 사용하여 s3 클라이언트를 호출했는지 어떻게 알 수 있습니까? mock을 사용하면 버킷 매개변수가 아닌 키 매개변수에 키 값을 전달했는지 확인할 수 있습니다.”
이것은 유효한 문제이며 어딘가 에서 테스트를 거쳐야 합니다. 여기서 내가 옹호하는 테스트 접근 방식은 버킷과 키 매개변수를 올바른 순서로 사용하여 Minio 클라이언트를 호출했는지 확인하지 않습니다.
내가 최근에 읽은 훌륭한 인용문은 "조롱은 위험을 초래하는 가정을 도입합니다[10]"라고 말했습니다. 클라이언트 라이브러리가 올바르게 구현되었다고 가정하고 모든 경계가 견고하다고 가정하고 라이브러리가 실제로 어떻게 작동하는지 알고 있다고 가정합니다.
라이브러리를 모의하는 것은 가정만 모의하고 코드를 업데이트할 때 테스트를 더 취약하게 만들고 변경될 수 있습니다(이는 Martin Fowler가 Mocks Aren't Stubs [3]에서 결론을 내린 내용입니다). 고무가 도로를 만나면 실제로 Minio 클라이언트를 올바르게 사용하고 있는지 확인해야 하며 이는 통합 테스트를 의미합니다(이는 Docker 설정 또는 테스트 환경에 있을 수 있음). 단위 테스트와 통합 테스트가 모두 있기 때문에 통합 테스트가 이를 다루므로 정확한 구현을 다루기 위해 단위 테스트가 필요하지 않습니다.
이 예에서 단위 테스트는 코드 디자인을 안내하고 오류와 논리 흐름이 설계된 대로 작동하는지 신속하게 테스트하여 필요한 작업을 정확하게 수행할 수 있도록 합니다.
어떤 사람들에게는 이것이 단위 테스트 커버리지가 충분하지 않다고 생각합니다. 그들은 위의 사항에 대해 걱정합니다. 일부는 다음과 같이 한 인터페이스가 다른 인터페이스를 반환하는 다른 인터페이스를 반환하는 러시아 인형 스타일 인터페이스를 주장합니다.
그런 다음 Minio 클라이언트의 각 부분을 각 래퍼로 가져온 다음 모의 생성기를 사용할 수 있습니다(빌드 및 테스트에 종속성 추가, 가정 증가, 상황을 더 취약하게 만들기). 마지막에 mockist는 다음과 같이 말할 수 있습니다.
myClientMock.ExpectsCall("GetObject").Returns(mockObject).NumberOfCalls(1).WithArgs(key, bucket) – 이 특정 DSL에 대한 올바른 주문을 기억할 수 있는 경우입니다.
이것은 Minio 클라이언트를 사용하는 구현 선택과 직접적으로 연결된 많은 추가 추상화가 될 것입니다. 이것은 클라이언트에 대한 가정을 변경해야 하거나 완전히 다른 클라이언트가 필요하다는 것을 알게 될 때 취약한 테스트를 야기합니다.
이것은 현재와 미래의 종단 간 코드 개발 시간을 추가하고, 코드 복잡성을 추가하고 가독성을 감소시키며, 잠재적으로 모의 생성기에 대한 종속성을 증가시키고, 버킷과 주요 매개변수를 혼합했는지 아는 모호한 부가 가치를 제공합니다. 어쨌든 통합 테스트에서 발견했을 것입니다.
점점 더 많은 물체가 도입될수록 결합은 점점 더 단단해집니다. 우리는 로거를 모의로 만들고 나중에 메트릭 모의를 시작했을 수 있습니다. 당신이 그것을 알기도 전에, 당신은 로그 항목이나 새로운 메트릭을 추가하고 있고 추가 메트릭이 올 것이라고 기대하지 않은 수많은 테스트를 방금 중단했습니다.
내가 마지막으로 Go에서 이것에 물렸을 때, 조롱 프레임워크는 새로운 메트릭을 발견했기 때문에 패닉에 빠져 끔찍한 죽음을 겪으면서 어떤 테스트나 파일이 실패했는지 말해주지도 않았습니다. 모의 동작을 변경해야 하는 위치를 찾을 수 있습니다. 모의가 가치를 더할 수 있습니까? 확신하는. 그만한 가치가 있습니까? 대부분의 경우 확신이 서지 않습니다.
인터페이스: 승리를 위한 단순성과 단위 테스트
Go에서 인터페이스를 간단히 사용하여 설계를 안내하고 적절한 코드와 오류 경로를 따를 수 있음을 보여주었습니다. 인터페이스를 준수하는 간단한 가짜를 작성함으로써 테스트용으로 설계된 코드를 생성하기 위해 모의, 모의 프레임워크 또는 모의 생성기가 필요하지 않음을 알 수 있습니다. 또한 단위 테스트가 전부는 아니며 시스템이 서로 적절하게 통합되도록 통합 테스트를 작성해야 합니다.
앞으로 통합 테스트를 실행하는 몇 가지 깔끔한 방법에 대한 게시물을 얻을 수 있기를 바랍니다. 계속 지켜봐 주세요!
참고문헌
1: Endo-Testing: Mock Objects를 사용한 단위 테스트(2000): Mock 객체의 정의에 대한 소개를 참조하십시오.
2: 작은 조롱꾼: 가짜에 대한 부분을 참조하십시오. 특히 "가짜에는 비즈니스 행동이 있습니다. 다른 데이터를 제공함으로써 가짜가 다른 방식으로 행동하도록 만들 수 있습니다.”
3: 모의는 스텁이 아니다: "그래서 나는 고전주의자가 되어야 하는가 아니면 모의주의자가 되어야 하는가?" 섹션을 보라. Martin Fowler는 "나는 mockist TDD에 대한 강력한 이점을 보지 못하고 테스트를 구현에 결합하는 결과에 대해 우려하고 있습니다."라고 말합니다.
4: 순진한 접근: 코드의 단순화된 버전. [7] 참조.
5: https://go-proverbs.github.io/: 대화 링크가 있는 바둑 속담 목록.
6: https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s: 인터페이스 크기 및 추상화와 관련하여 Rob Pike가 말한 직접 링크입니다.
7: 전체 버전의 데모 코드: 저장소를 복제하고 `테스트 실행`을 실행할 수 있습니다.
8: 테이블 기반 테스트: 중복을 줄이기 위해 테스트 코드를 구성하기 위한 테스트 전략.
9: 데모 코드의 전체 버전을 테스트합니다. 'go test'로 실행할 수 있습니다.
10: Michal Charemza의 테스트를 작성할 때 스스로에게 해야 할 질문: 조롱은 가정을 도입하고 가정은 위험을 초래합니다.