단위 테스트, 테스트 가능한 코드 작성 방법 및 중요한 이유

게시 됨: 2022-03-11

단위 테스트는 진지한 소프트웨어 개발자의 도구 상자에서 필수적인 도구입니다. 그러나 때로는 특정 코드 조각에 대해 좋은 단위 테스트를 작성하는 것이 상당히 어려울 수 있습니다. 자신이나 다른 사람의 코드를 테스트하는 데 어려움을 겪는 개발자는 종종 기본적인 테스트 지식이나 비밀 단위 테스트 기술이 부족하기 때문에 어려움을 겪는다고 생각합니다.

이 단위 테스트 튜토리얼에서는 단위 테스트가 매우 쉽다는 것을 보여주고자 합니다. 단위 테스트를 복잡하게 하고 값비싼 복잡성을 초래하는 실제 문제는 잘못 설계되고 테스트할 수 없는 코드의 결과입니다. 코드를 테스트하기 어렵게 만드는 요소, 테스트 가능성을 개선하기 위해 피해야 하는 안티패턴 및 나쁜 관행, 테스트 가능한 코드를 작성하여 얻을 수 있는 기타 이점에 대해 논의할 것입니다. 우리는 단위 테스트를 작성하고 테스트 가능한 코드를 생성하는 것이 테스트를 덜 번거롭게 만드는 것이 아니라 코드 자체를 더 강력하고 유지 관리하기 쉽게 만드는 것임을 알게 될 것입니다.

단위 테스트 튜토리얼: 표지 그림

단위 테스트 란 무엇입니까?

기본적으로 단위 테스트는 애플리케이션의 작은 부분을 인스턴스화하고 다른 부분과 독립적으로 동작을 확인하는 방법입니다. 일반적인 단위 테스트는 3단계를 포함합니다. 첫째, 테스트하려는 애플리케이션의 작은 부분(테스트 중인 시스템 또는 SUT라고도 함)을 초기화한 다음 테스트 중인 시스템에 약간의 자극을 적용합니다(일반적으로 방법), 마지막으로 결과 동작을 관찰합니다. 관찰된 동작이 예상과 일치하면 단위 테스트는 통과하고, 그렇지 않으면 실패하여 테스트 중인 시스템 어딘가에 문제가 있음을 나타냅니다. 이 세 가지 단위 테스트 단계는 Arrange, Act 및 Assert 또는 간단히 AAA라고도 합니다.

단위 테스트는 테스트 중인 시스템의 다양한 동작 측면을 확인할 수 있지만 대부분 상태 기반 또는 상호 작용 기반의 두 가지 범주 중 하나에 속합니다. 테스트 중인 시스템이 올바른 결과를 생성하는지 또는 결과 상태가 올바른지 확인하는 것을 상태 기반 단위 테스트라고 하며 특정 메서드를 제대로 호출하는지 확인하는 것을 상호 작용 기반 단위 테스트라고 합니다.

적절한 소프트웨어 단위 테스트에 대한 은유로 개구리 다리, 문어 촉수, 새 날개, 개의 머리가 있는 초자연적인 키메라를 만들고자 하는 미친 과학자를 상상해 보십시오. (이 은유는 프로그래머가 실제로 직장에서 하는 일에 매우 가깝습니다). 그 과학자는 그가 선택한 모든 부품(또는 단위)이 실제로 작동하는지 어떻게 확인할까요? 글쎄, 그는 개구리의 다리 하나를 가져 와서 전기 자극을 가하고 적절한 근육 수축을 확인할 수 있습니다. 그가 하는 일은 본질적으로 단위 테스트의 Arrange-Act-Assert 단계와 동일합니다. 유일한 차이점은 이 경우 단위 는 우리가 프로그램을 빌드하는 추상 객체가 아니라 물리적 객체를 참조한다는 것입니다.

단위 테스트란 무엇인가: 일러스트레이션

이 기사의 모든 예제에 C#을 사용하지만 설명된 개념은 모든 객체 지향 프로그래밍 언어에 적용됩니다.

간단한 단위 테스트의 예는 다음과 같습니다.

 [TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }

단위 테스트 대 통합 테스트

고려해야 할 또 다른 중요한 사항은 단위 테스트와 통합 테스트의 차이점입니다.

소프트웨어 엔지니어링에서 단위 테스트의 목적은 다른 부분과 독립적으로 상대적으로 작은 소프트웨어 부분의 동작을 확인하는 것입니다. 단위 테스트는 범위가 좁고 모든 사례를 다룰 수 있어 모든 단일 부분이 올바르게 작동하는지 확인할 수 있습니다.

반면에 통합 테스트는 시스템의 다른 부분 이 실제 환경에서 함께 작동 함을 보여줍니다. 그들은 복잡한 시나리오를 검증하며(통합 테스트는 사용자가 시스템 내에서 일부 고급 작업을 수행하는 것으로 생각할 수 있음) 일반적으로 데이터베이스 또는 웹 서버와 같은 외부 리소스가 있어야 합니다.

미친 과학자 비유로 돌아가서 그가 키메라의 모든 부분을 성공적으로 결합했다고 가정해 보겠습니다. 그는 생성된 생물의 통합 테스트를 수행하여 다른 유형의 지형을 걸을 수 있는지 확인하려고 합니다. 우선, 과학자는 생물이 걸을 수 있는 환경을 모방해야 합니다. 그런 다음 그는 그 생물을 그 환경에 던지고 막대기로 찔러 그것이 의도한 대로 걷고 움직이는지 관찰합니다. 테스트를 마친 미친 과학자는 현재 그의 아름다운 실험실에 흩어져 있는 모든 흙, 모래 및 암석을 청소합니다.

단위 테스트 예제 그림

단위 테스트와 통합 테스트의 중요한 차이점에 주목하십시오. 단위 테스트는 환경 및 다른 부분과 격리된 애플리케이션의 작은 부분의 동작을 확인하고 구현하기가 매우 쉬운 반면 통합 테스트는 서로 다른 구성 요소 간의 상호 작용을 다룹니다. 실제에 가까운 환경이며 추가 설정 및 분해 단계를 포함하여 더 많은 노력이 필요합니다.

단위 및 통합 테스트의 합리적인 조합은 모든 단일 단위가 다른 단위와 독립적으로 올바르게 작동하도록 하고 이러한 모든 단위가 통합될 때 원활하게 작동하여 전체 시스템이 예상대로 작동한다는 높은 수준의 확신을 줍니다.

그러나 우리는 우리가 구현하고 있는 테스트의 종류(단위 또는 통합 테스트)를 항상 식별해야 한다는 것을 기억해야 합니다. 그 차이는 때때로 기만적일 수 있습니다. 비즈니스 로직 클래스에서 미묘한 엣지 케이스를 확인하기 위해 단위 테스트를 작성하고 있다고 생각하고 웹 서비스나 데이터베이스와 같은 외부 리소스가 있어야 한다는 것을 깨닫는다면 뭔가 잘못된 것입니다. 본질적으로 우리는 큰 망치를 사용하여 너트를 깨십시오. 그리고 그것은 나쁜 디자인을 의미합니다.

좋은 단위 테스트를 만드는 것은 무엇입니까?

이 튜토리얼의 주요 부분으로 뛰어들어 단위 테스트를 작성하기 전에 좋은 단위 테스트의 속성에 대해 빠르게 논의해 보겠습니다. 단위 테스트 원칙에 따르면 좋은 테스트는 다음과 같아야 합니다.

  • 쓰기 쉽습니다. 개발자는 일반적으로 애플리케이션 동작의 다양한 사례와 측면을 다루기 위해 많은 단위 테스트를 작성하므로 엄청난 노력 없이도 이러한 모든 테스트 루틴을 쉽게 코딩할 수 있어야 합니다.

  • 읽을 수 있습니다. 단위 테스트의 의도는 명확해야 합니다. 좋은 단위 테스트는 애플리케이션의 일부 동작 측면에 대한 이야기를 제공하므로 테스트 중인 시나리오를 이해하기 쉽고 테스트가 실패할 경우 문제를 해결하는 방법을 쉽게 감지할 수 있어야 합니다. 좋은 단위 테스트를 통해 실제로 코드를 디버깅하지 않고도 버그를 수정할 수 있습니다!

  • 믿을 수있는. 단위 테스트는 테스트 중인 시스템에 버그가 있는 경우에만 실패해야 합니다. 그것은 꽤 분명한 것처럼 보이지만 프로그래머는 버그가 도입되지 않은 경우에도 테스트가 실패하면 종종 문제에 부딪힙니다. 예를 들어 테스트는 하나씩 실행할 때 통과할 수 있지만 전체 테스트 제품군을 실행할 때 실패하거나 개발 시스템에서는 통과하고 지속적 통합 서버에서는 실패할 수 있습니다. 이러한 상황은 설계 결함을 나타냅니다. 좋은 단위 테스트는 재현 가능해야 하며 환경이나 실행 순서와 같은 외부 요인과 무관해야 합니다.

  • 빠른. 개발자는 단위 테스트를 작성하여 반복적으로 실행하고 버그가 도입되지 않았는지 확인할 수 있습니다. 단위 테스트가 느리면 개발자는 자신의 컴퓨터에서 실행하는 것을 건너뛸 가능성이 더 큽니다. 한 번의 느린 테스트는 큰 차이를 만들지 않습니다. 1,000명을 더 추가하면 우리는 확실히 잠시 기다려야 합니다. 느린 단위 테스트는 테스트 중인 시스템이나 테스트 자체가 외부 시스템과 상호 작용하여 환경에 종속됨을 나타낼 수도 있습니다.

  • 통합이 아니라 진정한 단위입니다. 이미 논의한 바와 같이 단위 및 통합 테스트는 다른 목적을 가지고 있습니다. 단위 테스트와 테스트 중인 시스템 모두 외부 요인의 영향을 제거하기 위해 네트워크 리소스, 데이터베이스, 파일 시스템 등에 액세스해서는 안 됩니다.

그게 다야 — 단위 테스트 작성에는 비밀이 없습니다. 그러나 테스트 가능한 코드 를 작성할 수 있는 몇 가지 기술이 있습니다.

테스트할 수 있는 코드와 테스트할 수 없는 코드

어떤 코드는 좋은 단위 테스트를 작성하는 것이 어렵거나 심지어 불가능한 방식으로 작성되었습니다. 그렇다면 코드를 테스트하기 어렵게 만드는 것은 무엇입니까? 테스트 가능한 코드를 작성할 때 피해야 하는 몇 가지 안티 패턴, 코드 냄새 및 나쁜 관행을 검토해 보겠습니다.

비결정적 요인으로 코드베이스 중독

간단한 예부터 시작하겠습니다. 우리가 스마트 홈 마이크로컨트롤러를 위한 프로그램을 작성 중이고 요구 사항 중 하나가 저녁이나 밤에 뒤뜰에서 어떤 움직임이 감지되면 자동으로 조명을 켜는 것이라고 상상해 보십시오. 대략적인 시간("밤", "아침", "오후" 또는 "저녁")의 문자열 표현을 반환하는 메서드를 구현하여 아래에서 위로 시작했습니다.

 public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }

기본적으로 이 메서드는 현재 시스템 시간을 읽고 해당 값에 따라 결과를 반환합니다. 그렇다면 이 코드의 문제점은 무엇입니까?

단위 테스트 관점에서 생각해보면 이 방법에 대한 적절한 상태 기반 단위 테스트를 작성하는 것이 불가능하다는 것을 알 수 있습니다. DateTime.Now 는 본질적으로 프로그램 실행 중 또는 테스트 실행 사이에 변경될 숨겨진 입력입니다. 따라서 후속 호출은 다른 결과를 생성합니다.

이러한 비결정적 동작으로 인해 시스템 날짜 및 시간을 실제로 변경하지 않고 GetTimeOfDay() 메서드의 내부 논리를 테스트할 수 없습니다. 이러한 테스트가 어떻게 구현되어야 하는지 살펴보겠습니다.

 [TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }

이와 같은 테스트는 앞에서 논의한 많은 규칙을 위반합니다. 작성하는 데 비용이 많이 들고(사소하지 않은 설정 및 분해 논리로 인해), 신뢰할 수 없고(예: 시스템 권한 문제로 인해 테스트 중인 시스템에 버그가 없더라도 실패할 수 있음) 빨리 뛰어. 그리고 마지막으로, 이 테스트는 실제로 단위 테스트가 아닐 것입니다. 이는 단순한 엣지 케이스를 테스트하는 척하지만 특정 방식으로 환경을 설정해야 하기 때문에 단위와 통합 테스트 사이에 있을 것입니다. 결과는 노력할 가치가 없습니다, 응?

이러한 모든 테스트 가능성 문제는 품질이 낮은 GetTimeOfDay() API로 인해 발생합니다. 현재 형태에서 이 방법은 다음과 같은 몇 가지 문제가 있습니다.

  • 구체적인 데이터 소스와 밀접하게 연결되어 있습니다. 다른 소스에서 검색하거나 인수로 전달된 날짜 및 시간을 처리하는 데 이 메서드를 다시 사용할 수 없습니다. 이 메서드는 코드를 실행하는 특정 시스템의 날짜와 시간에만 작동합니다. 긴밀한 결합은 대부분의 테스트 가능성 문제의 주요 원인입니다.

  • 단일 책임 원칙(SRP)에 위배됩니다. 메서드에는 여러 가지 책임이 있습니다. 정보를 소비하고 처리하기도 합니다. SRP 위반의 또 다른 지표는 단일 클래스 또는 메서드가 변경해야 하는 이유 가 둘 이상인 경우입니다. 이러한 관점에서 GetTimeOfDay() 메서드는 내부 논리 조정으로 인해 변경되거나 날짜 및 시간 소스를 변경해야 하기 때문에 변경될 수 있습니다.

  • 그것은 작업을 완료하는 데 필요한 정보에 관한 것입니다. 개발자는 실제 소스 코드의 모든 행을 읽어서 숨겨진 입력이 사용된 것과 해당 입력이 어디에서 왔는지 이해해야 합니다. 메서드 서명만으로는 메서드의 동작을 이해하기에 충분하지 않습니다.

  • 예측하고 유지하기가 어렵습니다. 변경 가능한 전역 상태에 의존하는 메서드의 동작은 소스 코드를 읽는 것만으로는 예측할 수 없습니다. 이전에 값을 변경했을 수 있는 전체 이벤트 시퀀스와 함께 현재 값을 고려해야 합니다. 실제 응용 프로그램에서 이러한 모든 것을 해결하려고 하면 정말 골치 아픈 일이 됩니다.

API를 검토한 후 드디어 수정해보자! 다행스럽게도 이것은 모든 결점을 논의하는 것보다 훨씬 쉽습니다. 밀접하게 연결된 문제를 해결하기만 하면 됩니다.

API 수정: 메서드 인수 도입

API를 수정하는 가장 분명하고 쉬운 방법은 메서드 인수를 도입하는 것입니다.

 public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }

이제 메서드는 이 정보를 비밀리에 자체적으로 찾는 대신 호출자가 DateTime 인수를 제공하도록 요구합니다. 단위 테스트 관점에서 이것은 훌륭합니다. 메서드는 이제 결정적입니다(즉, 반환 값은 입력에 완전히 의존함). 따라서 상태 기반 테스트는 DateTime 값을 전달하고 결과를 확인하는 것만큼 쉽습니다.

 [TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }

이 간단한 리팩터링은 또한 처리해야 하는 데이터와 처리 방법 사이에 명확한 이음매를 도입하여 이전에 논의된 모든 API 문제(단단한 결합, SRP 위반, 불명확하고 이해하기 어려운 API)를 해결했습니다.

우수함 - 방법은 테스트할 수 있지만 클라이언트 는 어떻습니까? 이제 GetTimeOfDay(DateTime dateTime) 메서드에 날짜와 시간을 제공하는 것은 호출자의 책임입니다. 즉, 충분한 주의를 기울이지 않으면 테스트 수 없게 될 수 있습니다. 어떻게 대처할 수 있는지 살펴보겠습니다.

클라이언트 API 수정: 종속성 주입

스마트 홈 시스템에 대한 작업을 계속하고 GetTimeOfDay(DateTime dateTime) 메서드의 다음 클라이언트를 구현한다고 가정해 보겠습니다. 앞서 언급한 스마트 홈 마이크로컨트롤러 코드는 시간과 움직임 감지를 기반으로 조명을 켜거나 끄는 역할을 합니다. :

 public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }

아야! 우리는 같은 종류의 숨겨진 DateTime.Now 입력 문제가 있습니다. 유일한 차이점은 추상화 수준에서 조금 더 높은 곳에 위치한다는 것입니다. 이 문제를 해결하기 위해 ActuateLights(bool motionDetected, DateTime dateTime) 서명을 사용하여 새 메서드의 호출자에게 DateTime 값을 제공하는 책임을 다시 위임하는 또 다른 인수를 도입할 수 있습니다. 그러나 호출 스택에서 문제를 한 번 더 높은 수준으로 이동하는 대신 ActuateLights(bool motionDetected) 메서드와 해당 클라이언트를 모두 테스트 가능한 상태로 유지할 수 있는 또 다른 기술인 Inversion of Control 또는 IoC를 사용하겠습니다.

Inversion of Control은 간단하지만 코드 분리, 특히 단위 테스트에 매우 유용한 기술입니다. (결국, 느슨하게 결합된 상태를 유지하는 것은 서로 독립적으로 분석할 수 있는 데 필수적입니다.) IoC의 핵심은 의사 결정 코드( 무엇을 해야 할 때)와 액션 코드(무언가가 발생했을 때 무엇 해야 하는지)를 분리하는 것입니다. ). 이 기술은 유연성을 높이고 코드를 더 모듈화하며 구성 요소 간의 결합을 줄입니다.

제어 역전은 여러 가지 방법으로 구현할 수 있습니다. 생성자를 사용한 종속성 주입과 같은 특정 예를 살펴보고 테스트 가능한 SmartHomeController API를 빌드하는 데 어떻게 도움이 되는지 살펴보겠습니다.

먼저 날짜와 시간을 얻기 위한 메서드 서명이 포함된 IDateTimeProvider 인터페이스를 만들어 보겠습니다.

 public interface IDateTimeProvider { DateTime GetDateTime(); }

그런 다음 SmartHomeControllerIDateTimeProvider 구현을 참조하도록 만들고 날짜와 시간을 가져오는 책임을 위임합니다.

 public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }

이제 Inversion of Control이 왜 그렇게 불리는지 알 수 있습니다. 날짜와 시간을 읽는 데 사용할 메커니즘에 대한 제어반전 되었으며 이제 SmartHomeController 자체가 아니라 SmartHomeController클라이언트 에 속합니다. 따라서 ActuateLights(bool motionDetected) 메서드의 실행은 외부에서 쉽게 관리할 수 있는 두 가지, 즉 motionDetected 인수와 SmartHomeController 생성자에 전달된 IDateTimeProvider 의 구체적인 구현에 전적으로 의존합니다.

이것이 단위 테스트에 중요한 이유는 무엇입니까? 이는 프로덕션 코드와 단위 테스트 코드에서 서로 다른 IDateTimeProvider 구현을 사용할 수 있음을 의미합니다. 프로덕션 환경에서 일부 실제 구현이 주입됩니다(예: 실제 시스템 시간을 읽는 구현). 그러나 단위 테스트에서 특정 시나리오 테스트에 적합한 상수 또는 미리 정의된 DateTime 값을 반환하는 "가짜" 구현을 주입할 수 있습니다.

IDateTimeProvider 의 가짜 구현은 다음과 같습니다.

 public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }

이 클래스의 도움으로 SmartHomeController 를 비결정적 요소로부터 격리하고 상태 기반 단위 테스트를 수행할 수 있습니다. 움직임이 감지되면 해당 움직임의 시간이 LastMotionTime 속성에 기록되는지 확인합니다.

 [TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }

엄청난! 리팩토링 전에는 이런 테스트가 불가능했습니다. 이제 비결정적 요소를 제거하고 상태 기반 시나리오를 확인 SmartHomeController 를 완전히 테스트할 수 있다고 생각하십니까?

부작용이 있는 코드베이스 중독

비결정적 숨겨진 입력으로 인해 발생하는 문제를 해결하고 특정 기능을 테스트할 수 있었음에도 불구하고 코드(또는 적어도 일부)는 여전히 테스트할 수 없습니다!

조명을 켜거나 끄는 ActuateLights(bool motionDetected) 메서드의 다음 부분을 검토해 보겠습니다.

 // If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }

보시다시피, SmartHomeController 는 싱글톤 패턴을 구현하는 BackyardLightSwitcher 객체에 조명을 켜거나 끄는 책임을 위임합니다. 이 디자인에 무슨 문제가 있습니까?

ActuateLights(bool motionDetected) 메서드를 완전히 단위 테스트하려면 상태 기반 테스트 외에도 상호 작용 기반 테스트를 수행해야 합니다. 즉, 적절한 조건이 충족되는 경우에만 조명을 켜거나 끄는 메서드가 호출되도록 해야 합니다. 불행히도 현재 디자인에서는 그렇게 할 수 없습니다. BackyardLightSwitcherTurnOn()TurnOff() 메서드는 시스템의 일부 상태 변경을 트리거하거나, 즉 부작용 을 생성합니다. 이러한 메서드가 호출되었는지 확인하는 유일한 방법은 해당하는 부작용이 실제로 발생했는지 여부를 확인하는 것입니다. 이는 고통스러울 수 있습니다.

실제로 모션 센서, 뒤뜰 랜턴 및 스마트 홈 마이크로 컨트롤러가 사물 인터넷 네트워크에 연결되어 일부 무선 프로토콜을 사용하여 통신한다고 가정해 보겠습니다. 이 경우 단위 테스트는 해당 네트워크 트래픽을 수신하고 분석하려고 시도할 수 있습니다. 또는 하드웨어 구성 요소가 전선으로 연결되어 있는 경우 단위 테스트를 통해 해당 전기 회로에 전압이 인가되었는지 확인할 수 있습니다. 아니면 결국 별도의 광센서를 이용하여 조명이 실제로 켜지거나 꺼지는지 확인할 수 있습니다.

우리가 볼 수 있듯이, 부작용이 있는 단위 테스트 방법은 비결정적 단위 테스트만큼 어려울 수 있으며 심지어 불가능할 수도 있습니다. 모든 시도는 우리가 이미 본 것과 유사한 문제로 이어질 것입니다. 결과 테스트는 구현하기 어렵고 신뢰할 수 없으며 잠재적으로 느리고 단위가 아닙니다. 그리고 결국 테스트 스위트를 실행할 때마다 깜박이는 불빛은 결국 우리를 미치게 만들 것입니다!

다시 말하지만, 이러한 모든 테스트 가능성 문제는 단위 테스트를 작성하는 개발자의 능력이 아니라 잘못된 API로 인해 발생합니다. 조명 제어가 얼마나 정확하게 구현되었는지에 관계없이 SmartHomeController API는 다음과 같은 이미 익숙한 문제를 겪고 있습니다.

  • 구체적인 구현과 밀접하게 연결되어 있습니다. API는 BackyardLightSwitcher 의 하드 코딩된 구체적인 인스턴스에 의존합니다. ActuateLights(bool motionDetected) 메서드를 재사용하여 뒤뜰에 있는 조명이 아닌 다른 조명을 전환하는 것은 불가능합니다.

  • 단일 책임 원칙에 위배됩니다. API를 변경해야 하는 두 가지 이유가 있습니다. 첫째, 내부 논리 변경(예: 밤에만 조명을 켜고 저녁에는 켜지지 않도록 선택)과 둘째, 조명 전환 메커니즘이 다른 메커니즘으로 교체되는 경우입니다.

  • 그것은 의존성에 관한 것입니다. 개발자는 소스 코드를 파헤치는 것 SmartHomeController 가 하드 코딩된 BackyardLightSwitcher 구성 요소에 의존한다는 것을 알 수 있는 방법이 없습니다.

  • 이해하고 유지하기가 어렵습니다. 조건이 맞을 때 조명이 켜지지 않으면 어떻게 됩니까? 우리는 SmartHomeController 를 아무 소용이 없도록 수정하는 데 많은 시간을 할애할 수 있었지만, 문제가 BackyardLightSwitcher 의 버그(또는 더 웃긴 것은 전구가 타버린 것)에 의해 발생했다는 것을 깨달았습니다.

테스트 가능성과 낮은 품질의 API 문제에 대한 해결책은 당연히 밀접하게 연결된 구성 요소를 서로 분리하는 것입니다. 이전 예와 마찬가지로 종속성 주입을 사용하면 이러한 문제를 해결할 수 있습니다. SmartHomeControllerILightSwitcher 종속성을 추가하고 조명 스위치를 뒤집는 책임을 위임하고 올바른 조건에서 적절한 메서드가 호출되었는지 여부를 기록하는 가짜 테스트 전용 ILightSwitcher 구현을 전달하면 됩니다. 그러나 Dependency Injection을 다시 사용하는 대신 책임을 분리하는 흥미로운 대안을 검토해 보겠습니다.

API 수정: 고차 함수

이 접근 방식은 일급 기능 을 지원하는 모든 객체 지향 언어의 옵션입니다. C#의 기능적 특징을 활용하고 ActuateLights(bool motionDetected) 메서드가 두 개의 추가 인수를 허용하도록 합시다. 한 쌍의 Action 대리자는 조명을 켜고 끄기 위해 호출해야 하는 메서드를 가리킵니다. 이 솔루션은 메서드를 고차 함수 로 변환합니다.

 public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }

이것은 우리가 전에 본 고전적인 객체 지향 의존성 주입 접근 방식보다 더 기능적인 솔루션입니다. 그러나 종속성 주입보다 적은 코드와 더 많은 표현력으로 동일한 결과를 얻을 수 있습니다. SmartHomeController 에 필요한 기능을 제공하기 위해 인터페이스를 준수하는 클래스를 더 이상 구현할 필요가 없습니다. 대신 함수 정의를 전달할 수 있습니다. 고차 함수는 Inversion of Control을 구현하는 또 다른 방법으로 생각할 수 있습니다.

이제 결과 메서드의 상호 작용 기반 단위 테스트를 수행하기 위해 쉽게 확인할 수 있는 가짜 작업을 메서드에 전달할 수 있습니다.

 [TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }

마지막으로 SmartHomeController API를 완전히 테스트할 수 있게 했으며 이에 대해 상태 기반 및 상호 작용 기반 단위 테스트를 모두 수행할 수 있습니다. 다시 말하지만, 향상된 테스트 가능성 외에도 의사 결정과 작업 코드 사이에 이음매를 도입하면 긴밀한 결합 문제를 해결하는 데 도움이 되었고 더 깨끗하고 재사용 가능한 API로 이어졌습니다.

이제 전체 단위 테스트 범위를 달성하기 위해 유사한 모양의 테스트를 구현하여 가능한 모든 경우를 검증할 수 있습니다. 이제 단위 테스트를 구현하기가 매우 쉽기 때문에 큰 문제는 아닙니다.

불순물 및 테스트 가능성

통제되지 않은 비결정성과 부작용은 코드베이스에 대한 파괴적인 영향과 유사합니다. 부주의하게 사용하면 기만적이고, 이해하고 유지하기 어렵고, 밀접하게 결합되고, 재사용할 수 없고, 테스트할 수 없는 코드가 됩니다.

반면에 결정론 적이고 부작용이 없는 방법은 테스트하고 추론하고 더 큰 프로그램을 빌드하기 위해 재사용하기가 훨씬 쉽습니다. 함수형 프로그래밍의 관점에서 이러한 메서드를 순수 함수 라고 합니다. 우리는 순수 함수를 테스트하는 문제가 거의 없을 것입니다. 우리가 해야 할 일은 몇 가지 인수를 전달하고 결과가 정확한지 확인하는 것입니다. 실제로 코드를 테스트할 수 없게 만드는 것은 다른 방식으로 대체, 재정의 또는 추상화할 수 없는 하드 코딩된 불순한 요소입니다.

불순물은 유독합니다. Foo() 메서드가 비결정적이거나 부작용이 있는 Bar() 메서드에 종속되면 Foo() 도 비결정적이거나 부작용이 있습니다. 결국 우리는 전체 코드베이스를 오염시킬 수 있습니다. 이 모든 문제에 복잡한 실제 응용 프로그램의 크기를 곱하면 냄새, 안티 패턴, 비밀 종속성 및 모든 종류의 추하고 불쾌한 것들로 가득 찬 코드베이스를 유지 관리하기 어려운 문제에 봉착하게 될 것입니다.

단위 테스트 예제: 일러스트레이션

그러나 불순물은 불가피합니다. 실제 응용 프로그램은 어느 시점에서 환경, 데이터베이스, 구성 파일, 웹 서비스 또는 기타 외부 시스템과 상호 작용하여 상태를 읽고 조작해야 합니다. 따라서 불순물을 완전히 제거하는 것을 목표로 하는 대신 이러한 요소를 제한하고 코드베이스에 독이 되지 않도록 하며 가능한 한 하드 코딩된 종속성을 깨뜨려 독립적으로 항목을 분석하고 단위 테스트할 수 있도록 하는 것이 좋습니다.

테스트하기 어려운 코드의 일반적인 경고 신호

테스트 작성에 문제가 있습니까? 문제는 테스트 제품군에 없습니다. 그것은 당신의 코드에 있습니다.
트위터

마지막으로 코드를 테스트하기 어려울 수 있음을 나타내는 몇 가지 일반적인 경고 신호를 검토해 보겠습니다.

정적 속성 및 필드

정적 속성 및 필드 또는 간단히 말해 전역 상태는 메서드가 작업을 완료하는 데 필요한 정보를 숨기거나, 비결정성을 도입하거나, 부작용의 광범위한 사용을 조장함으로써 코드 이해와 테스트 가능성을 복잡하게 만들 수 있습니다. 변경 가능한 전역 상태를 읽거나 수정하는 함수는 본질적으로 순수하지 않습니다.

예를 들어 전역적으로 액세스 가능한 속성에 따라 달라지는 다음 코드에 대해 추론하기 어렵습니다.

 if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }

HeatWater() 메서드가 호출되어야 한다고 확신할 때 호출되지 않으면 어떻게 됩니까? 응용 프로그램의 일부가 CostSavingEnabled 값을 변경했을 수 있으므로 무엇이 잘못되었는지 알아내기 위해 해당 값을 수정하는 모든 위치를 찾고 분석해야 합니다. 또한 이미 보았듯이 테스트 목적으로 일부 정적 속성을 설정하는 것은 불가능합니다(예: DateTime.Now 또는 Environment.MachineName ; 읽기 전용이지만 여전히 비결정적임).

반면에 불변 하고 결정적인 전역 상태는 완전히 괜찮습니다. 사실, 이것에 대해 더 친숙한 이름인 상수가 있습니다. Math.PI 와 같은 상수 값은 비결정성을 도입하지 않으며 값을 변경할 수 없으므로 부작용을 허용하지 않습니다.

 double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!

싱글톤

본질적으로 싱글톤 패턴은 전역 상태의 또 다른 형태일 뿐입니다. 싱글톤은 실제 종속성에 대해 거짓말을 하고 구성 요소 간에 불필요하게 긴밀한 결합을 도입하는 모호한 API를 촉진합니다. 그들은 또한 기본 의무 외에도 자체 초기화 및 수명 주기를 제어하기 때문에 단일 책임 원칙을 위반합니다.

싱글톤은 전체 애플리케이션 또는 단위 테스트 제품군의 수명 동안 상태를 전달하기 때문에 단위 테스트를 순서 종속적으로 쉽게 만들 수 있습니다. 다음 예를 살펴보십시오.

 User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }

In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache after each unit test run.

Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.

The new Operator

Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.

For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:

 using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }

However, sometimes new is absolutely harmless: for example, it is OK to create simple entity objects:

 var person = new Person("John", "Doe", new DateTime(1970, 12, 31));

It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack methods were called or not — we just check if the end result is correct:

 string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }

정적 메서드

Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.

For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:

 void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }

However, pure static functions are OK: any combination of them will still be a pure function. 예를 들어:

 double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }

Benefits of Unit Testing

Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.

As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.