프로그래밍/C++

WinUnit

Subi Lee 2010. 4. 3.
반응형
WinUnit
네이티브 C++ 응용 프로그램을 위한 간단한 단위 테스트
Maria Blees

이 기사에서 다루는 내용:
  • 단위 테스트의 개념
  • WinUnit 설정
  • 테스트 fixture 빌드
  • 구현 및 사용자 지정
이 기사에서 사용하는 기술: 
C++, Visual Studio
이 기사의 코드 다운로드: WinUnit2008_02.exe (1438 KB) 
온라인으로 코드 찾아보기 
최근 네이티브 코드 개발자라고 하면 조금은 외면을 받는 경향도 없지 않아 있습니다. Microsoft®.NET Framework가 만능 도구로 인정받고 있기 때문입니다.
필자는 개인적으로 항상 훌륭한 엔지니어링 사례에 관심이 있었지만, 막상 단위 테스트의 중요성을 강조하는 소위 "엔지니어링 전문가"에게 네이티브 코드에서 사용할 도구를 추천해달라고 하면 제대로 된 답을 얻을 수 없어서 실망하곤 했습니다. 네이티브 코드에서도 NUnit에 버금가는 자동화된 빌드 시스템으로 쉽게 통합할 수 있는 도구가 있으면 얼마나 좋을까요? 즉 테스트 전용 DDL을 만들고, 이 테스트를 실행하며 보고 및 로깅을 관리하는 외부 테스트 실행기가 필요한 것입니다. 또한 각 테스트를 한 번만 선언하고 테스트 DLL에서 최소의 추가 코드만 작성하면 되는 도구가 필요했습니다.
그래서 WinUnit라고 하는 새 네이티브 코드 단위 테스트 도구를 만들었습니다. 자세한 설명은 뒤로 미루고 먼저 WinUnit를 사용하여 쉽게 테스트 DLL을 만들고 실행하는 방법을 간단히 살펴보겠습니다. 먼저 DummyTest.cpp라고 하는 CPP 파일을 하나 만듭니다.
#include "WinUnit.h"

BEGIN_TEST(DummyTest)
{
   WIN_ASSERT_TRUE(3 > 4, _T("You should see this error message."));
}
END_TEST
다음 명령을 사용하여 DDL로 빌드합니다.
cl /EHsc /LD DummyTest.cpp
이제 여기서 WinUnit를 실행합니다.
>WinUnit DummyTest.dll
Processing [DummyTest.dll]...
(DummyTest)
DummyTest.cpp(5): error : WIN_ASSERT_TRUE failed: "3 > 4". You should see this error message.
FAILED: DummyTest.
[DummyTest.dll] FAILED.  Tests run: 1; Failures: 1.

There were errors.
Tests run: 1; Failed: 1.
물론 여기서 3 > 4를 4 > 3과 같은 참인 식으로 변경하면 테스트는 실패하지 않습니다.
WinUnit는 수에 제한 없이 DLL이나 디렉터리를 처리하고 전체 결과를 보고한 후 종료 코드와 함께 반환합니다. 단, WinUnit는 Windows®에서만 작동한다는 점에 주의하십시오.
이 도구를 만들기 전에도 C++ 단위 테스트가 조금씩 사용되긴 했지만, 보통 기존에 사용하던 도구는 이식성을 강조했기 때문에 이해가 쉽고 바로 사용할 수 있는 도구라고는 할 수 없었습니다. CppUnitLite는 이러한 이식 가능한 도구 중 필자가 가장 선호하는 도구지만, 사용자 지정 기능이 너무 많아서 설정할 때 예상보다 많은 오버헤드가 발생합니다. 또한 테스트 실행기와 테스트가 같은 바이너리 내에서 수행되고 실제로 테스트 실행 기능을 호출하는 코드는 다른 곳에서 작성하여 삽입해야 하므로 둘을 명확히 구분하기 어렵습니다. 또한 매크로 구현도 까다로워서 동료에게 사용을 권하기에도 무리가 있습니다. 그러나 다른 운영 체제로의 이식성이 필요한 경우에는 CppUnitLite가 제 기능을 발휘합니다.
C++ 단위 테스트에 사용되어온 다른 옵션으로는 널리 사용되는 NUnit와 Visual Studio® Team Test 단위 테스트 프레임워크와 같이 .NET 코드를 테스트할 때 사용할 수 있는 도구가 있습니다. 둘 다 적절히 특성을 지정한 .NET 어셈블리를 실행하므로 C++ 관리 코드(현재 C++/CLI)로 작성된 테스트에서는 이 도구를 사용할 수 있습니다. 단, 이 도구는 네이티브 코드가 아닌 .NET 개발용으로 설계되었다는 점이 가장 큰 단점입니다. 따라서 .NET 스타일 개체가 예상되는 경우라면 네이티브 C++와 함께 제공된 Assert 메서드는 대부분 사용하기 어려우며 개발자가 C++/CLI 또는 Managed Extensions for C++를 다룰 줄 알아야 한다는 단점이 있습니다. Visual Studio 2005 제품 기능 비교 페이지(msdn2.microsoft.com/vstudio/aa700921.aspx)의 각주에서 이 방법의 제한 사항에 대한 자세한 내용을 확인할 수 있습니다. 앞에서 말한 대로 "동료에게 사용을 권장"할 수 있어야 한다는 점이 중요합니다. 하지만 관리 도구를 사용하면 이 점에서 또 다른 걸림돌이 생깁니다.

테스트 시작
코드 검사와 편리함
Visual Studio Team System(VSTS)의 기능에 대해 간략히 설명하겠습니다. 이 도구는 코드 검사 도구이며 단위 테스트와 관련이 있습니다. 코드 검사 통계의 중요성은 논란의 여지가 있지만 신중하게 테스트를 생성한 후 검사를 검토하면 뛰어난 온전성 검사 기능을 확인할 수 있습니다.
안타깝게도 VSTS에서는 네이티브 코드를 대상으로 코드 검사 도구를 쉽게 사용할 수 없습니다. 심지어 어떤 도구를 사용해야 하는지 확인하기도 어렵습니다. 그래서 이 프로젝트를 위한 코드와 함께 네이티브 프로젝트에서 코드 검사를 쉽게 수행할 수 있는 매크로 집합과 WinUnit 및 코드를 연습할 수 있는 다른 시나리오도 포함시켰습니다.
WinUnit.vsmacros를 Visual Studio의 매크로 탐색기로 로드하기만 하면 이러한 매크로를 사용할 수 있습니다. _Variables 모듈에서는 특정 로컬 시스템에 맞는 변수를 설정해야 합니다. 이때 설정할 변수로는 Visual Studio 성능 도구(코드 검사 도구 포함)에 대한 경로와 WinUnit.exe 및 WinUnit.h에 대한 경로가 포함됩니다. 자세한 내용은 readme.txt 및 매크로 프로젝트의 _Readme 모듈을 참조하십시오.
CodeCoverage 모듈에는 검사 데이터 수집을 시작 및 중지하는 매크로, 바이너리를 계측하는 매크로(이때 각 프로젝트에서 계측할 바이너리 목록을 지정할 수 있음), 결과로 검사 파일을 실행하는 매크로가 포함되어 있습니다.
RunningTests 모듈에는 현재 열린 문서의 커서를 포함하는 테스트, 현재 열린 파일의 모든 테스트 및 선택한 프로젝트의 모든 테스트를 실행하는 매크로를 포함하고 있습니다. 다른 매크로에서는 관련 프로젝트 설정을 변경할 수 있는 바로 가기와 코드 검사를 설정한 상태에서 다른 테스트 집합을 실행할 수 있는 바로 가기를 제공합니다.

WinUnit 사용 방법으로 들어가기에 앞서 일반적인 측면에서 단위 테스트의 개념과, 이 중에서도 특히 네이티브 코드의 단위 테스트에 대한 개념을 간단히 짚고 넘어가겠습니다. 단위 테스트 예제에 나오는 깔끔하고 세련된 모듈식 클래스는 보통 사용하는 레거시 코드의 BLOB과 아무런 공통점이 없습니다. 먼저 전체 코드 기반을 다시 구성해야만 단위 테스트를 할 수 있다고 생각한다면 먼 훗날에나 테스트를 수행할 수 있을 것입니다. 하지만 굳이 깔끔하고 세련된 클래스가 아니어도 단위 테스트를 할 수 있습니다. 분리하여 테스트할 수 있는 최소의 코드 단위이기만 하면 됩니다. 그 최소 단위가 하나의 응용 프로그램이라면 두말할 것도 없지요!
응용 프로그램이 하나의 실행 파일로만 구성되었고 유일한 인터페이스가 GUI인 경우 GUI가 아닌 부분을 자동화하기 위해 리팩터링 작업을 수행해야 할 수도 있습니다. 그러나 이 작업은 차차 해나가면 됩니다. GUI 응용 프로그램의 테스트 환경을 조성하는 바람직한 방법은 Michael Feathers가 쓴 "The Humble Dialog Box" 백서(objectmentor.com/resources/articles/TheHumbleDialogBox.pdf)를 참조하십시오.
여기서 궁극적인 목표는 개발자가 버그를 수정하거나 새 코드를 작성할 때 테스트를 쉽게 추가할 수 있도록 빌드 프로세스 중에 개발자 테스트를 자동으로 실행시키는 것입니다. 일단 인프라를 설정하면 문제 영역에서 테스트 검사 성능을 향상시킬 수 있도록 기존 코드를 리팩터링할 수 있습니다. Michael Feathers의 저서 Working Effectively with Legacy Code(Prentice Hall, 2004)에서는 이 프로세스를 이해하기 쉽게 설명하고 있으며 C++에 관한 많은 예제도 제공합니다.
응용 프로그램이 이미 명확히 정의된 인터페이스를 통해 정적 라이브러리로 분리된 경우 이 라이브러리를 직접 테스트 DLL에 연결하여 개별 클래스 단위로 테스트할 수 있습니다. 인터페이스 경계를 명확히 유지하려면 라이브러리당 하나의 테스트 DLL을 사용하는 것이 좋습니다.
응용 프로그램이 별도의 DLL을 포함하거나 응용 프로그램 자체가 DLL 형식으로 제공되는 라이브러리인 경우 두 가지 방법으로 내보낸 DLL에 액세스하여 테스트할 수 있습니다. 가져오기 라이브러리를 사용하는 경우 테스트 DLL에 연결하여 테스트를 위한 프로덕션 기능에 명확하게 액세스할 수 있습니다. 가져오기 라이브러리를 사용하지 않는 경우 LoadLibrary 및 FreeLibrary와 함께 GetProcAddress를 사용하여 DLL을 내보내고 테스트에서 직접 호출할 수 있습니다.
DLL이 COM DLL인 경우에는 테스트 대상으로 등록하지 않아도 됩니다. Windows XP 이상에서는 "RegFree COM" 메커니즘을 제공하므로 COM DLL인 경우에는 일반적인 등록 요구 사항이 적용되지 않습니다. 자세한 내용은 msdn2.microsoft.com/ms973913을 참조하십시오. 또한 일단 테스트가 성공하면 프로덕션 코드에서 해당 기능을 사용할 수도 있습니다.
응용 프로그램이 하나의 실행 파일인 경우 이를 하나 이상의 라이브러리로 분리하고자 하는 경우도 있을 것입니다. 그러나 실행 파일을 통째로 테스트하여 적절한 종료 코드가 있는지 확인하려 할 수도 있습니다. 필자가 WinUnit에서 수행한 단위 테스트 중에는 전체 실행 파일을 대상으로 하는 테스트도 들어 있습니다. 이 테스트는 제공되는 코드 다운로드의 TestWinUnit 프로젝트(MainTest.cpp 파일)에 있습니다.
스타일에 너무 엄격한 것일 수도 있지만, 물리적으로 프로덕션 코드를 구성하는 방법에 대해 한 가지 지적할 사항이 있습니다. 여기서 언급하지 않았다면 또 그대로 지나칠 뻔했습니다. Microsoft에서는 수년간 대형 C++ 프로젝트를 진행하면서 테스트 단위를 분리하거나 테스트를 실행하는 데 가장 큰 장애물은 임의의 .cpp 파일과 .h 파일에서 여러 클래스가 뒤죽박죽으로 섞여 있는 상황임을 알아냈습니다. 프로덕션 코드에서 클래스를 정연하게 표현하는 방법은 사용자에게 달렸습니다. 이름이 비슷한 .cpp 파일과 .h 파일 한 쌍에 하나의 클래스를 연관시키는 것도 괜찮은 방법입니다. 또한 이렇게 하면 각 클래스에서 일치하는 테스트 파일이 하나이므로 테스트 구성도 단순해집니다. 프로젝트 WinUnitLib와 TestWinUnit에 몇 가지 관련 예제가 있으니 참조하십시오.
단위 테스트의 모든 개념을 살펴보거나 개발자가 테스터의 시각을 갖추고 싶다면 Andrew Hunt와 David Thomas가 쓴 Pragmatic Unit Testing with C# in NUnit(Pragmatic Bookshelf, 2006)가 좋은 입문서가 될 수 있습니다. 이 서적에서는 Java도 다룹니다. 제목을 보면 특정 언어만을 다루는 것 같지만 실제로 내용은 어느 한 언어에 한정되지는 않습니다. 이 서적의 핵심을 한 단어로 요약하면 단위 테스트는 A-TRIP, 즉 Automatic(자동성), Thorough(전체성), Repeatable(반복성), Independent(독립성), Professional(전문성)과 같은 특징을 가져야 한다는 것입니다. 여기서 반복성은 실행할 때마다 테스트 결과가 같아야 함을 나타내고, 독립성은 서로 종속되지 않고 임의의 순서로 테스트를 실행할 수 있음을 나타냅니다. 이 특징은 나중에 다시 다루겠습니다.

WinUnit 시작
먼저 WinUnit를 빌드하고 WinUnit.exe 및 WinUnit.h를 시스템의 알려진 위치에 배치해야 합니다. 자세한 내용은 코드에 포함된 readme.txt를 참조하십시오. 그 다음 테스트 작성을 시작하기 위해 테스트 프로젝트를 만듭니다. 앞서 말한 대로 보통 C++ DLL입니다. Visual Studio에서 네이티브 C++ DLL 프로젝트를 만드는 방법은 동적 연결 라이브러리 작성 및 사용(C++)(msdn2.microsoft.com/ms235636)의 연습을 참조하십시오.
다른 단위 테스트 프레임워크와 마찬가지로 WinUnit는 테스트 작성 편의를 위해 앞서 보았던 WIN_ASSERT_TRUE와 같은 여러 Assert 매크로를 제공합니다. WinUnit에서 사용할 수 있는 다른 WIN_ASSERT 매크로는 나중에 설명하겠습니다. Assert는 C++ 예외를 사용하여 작동합니다. 그래서 테스트 DLL을 빌드할 때에는 Visual Studio 2005에서 기본 컴파일러 옵션으로 /EHsc를 설정해야 합니다. 하지만 테스트 DLL에 연결했어도 프로덕션 코드에서 C++ 예외 처리를 사용하는 데 특별한 요구 사항은 없습니다. 단, Visual C++® 2005 이상의 도구 집합을 사용하려면 제공된 Assert 매크로를 사용해야 합니다. WinUnit의 Include 디렉터리를 테스트 프로젝트의 포함 경로(그림 1 참조) 또는 전역 포함 경로에 추가할 수도 있습니다.
그림 1 프로젝트의 포함 경로 설정 (더 크게 보려면 이미지를 클릭하십시오.)
WinUnit는 명령줄에서도 실행할 수 있으므로 빌드할 때마다 테스트 프로젝트에서 WinUnit를 실행하도록 Visual Studio를 설정할 수 있습니다. 그러려면 먼저 테스트 프로젝트의 프로젝트 속성으로 이동하여 구성 속성 | 빌드 이벤트 아래에서 빌드 후 이벤트를 선택합니다(그림 2 참조). 명령줄에 WinUnit.exe를 복사한 위치의 전체 경로를 입력한 다음, 큰따옴표를 포함하여 "$(TargetPath)"를 입력하고 설명에 "Running WinUnit..."를 입력합니다. 이때 큰따옴표는 포함하지 않습니다. WinUnit.exe가 있는 폴더를 전역 실행 파일 경로에 추가하면 여기서 전체 경로를 지정하지 않아도 됩니다.
그림 2 사전 빌드 이벤트로 WinUnit를 구성 (더 크게 보려면 이미지를 클릭하십시오.)
또는 WinUnit.exe를 도구 메뉴에 추가하여 테스트를 실행할 수도 있습니다. 그러려면 도구 | 외부 도구로 이동하여 추가를 클릭합니다. 제목에 "&WinUnit"를 입력하고 명령에서 WinUnit.exe를 찾습니다. 인수에 큰따옴표를 포함하여 "$(TargetPath)"를 입력하고 초기 디렉터리에 $(TargetDir)을 입력합니다. 끝날 때 닫기의 선택을 취소하고 출력 창 사용을 선택합니다(그림 3 참조). 이제 도구 메뉴에서 WinUnit를 선택하면 현재 선택한 프로젝트에서 실행할 수 있습니다.
그림 3 WinUnit를 외부 도구로 추가 
마지막으로 프로젝트에서 디버깅 기능을 설정하려면 속성 | 구성 속성 | 디버깅으로 이동한 다음, 명령 텍스트 상자에 WinUnit.exe의 전체 경로를 입력합니다. 명령 인수에는 큰따옴표를 포함하여 "$(TargetPath)"를 입력합니다.
프로젝트의 모든 항목이 제대로 설정되었는지 확인하려면 DummyTest.cpp를 프로젝트에 추가하여 빌드합니다. 프로젝트가 빌드되지만 앞에서와 같이 거짓인 Assert 행을 입력하면 해당 행에서 테스트가 실패합니다.
이제 설정을 마쳤으므로 WinUnit 함수를 사용하여 테스트를 작성하는 다른 방법을 살펴보겠습니다. 지금 제공된 예제를 사용하여 WinUnitComplete.sln으로 전환하고자 합니다. 예제를 포함하는 프로젝트는 SampleLib과 TestSampleLib입니다. SampleLib은 하나의 클래스, BinaryNumber만 포함하는 정적 라이브러리이고 TestSampleLib은 SampleLib.lib과 연결되어 있으며 몇 가지 다른 예제와 함께 BinaryNumber 클래스에 대한 테스트를 포함하는 테스트 DLL입니다.
일반적인 WinUnit 테스트 함수는 BEGIN_TEST(TestName)로 시작하여 END_TEST로 끝나며, 각 테스트 내에서 하나 이상의 WIN_ASSERT 매크로를 사용하여 다양한 기능 비트를 확인합니다.
BinaryNumber 클래스 예제에는 2개의 생성자가 있습니다. 하나는 부호 없는 short를 취하고 다른 하나는 1과 0으로 구성된 문자열을 취합니다. 여기서는 이 구성자를 통해 같은 값을 전달하면 같은 개체임을 확인합니다. 다음과 같이 테스트할 수 있습니다.
BEGIN_TEST(BinaryNumberConstructorsShouldBeEquivalent)
{
  unsigned short numericValue = 7;
  BinaryNumber bn1(numericValue);
  BinaryNumber bn2("111");
  WIN_ASSERT_EQUAL(bn1.NumericValue, bn2.NumericValue, 
    _T("Both values should be %u."), numericValue);
  WIN_ASSERT_STRING_EQUAL(bn1.StringValue, bn2.StringValue);
}
END_TEST
두 Assert 행 중 하나라도 실패하면 테스트가 실패합니다. 이 예제의 WIN_ASSERT_EQUAL 매크로는 비교할 두 개의 값과 두 개의 추가 인수를 전달합니다. 이때 추가 인수는 Assert에 실패하면 표시되는 정보 메시지입니다. 모든 WIN_ASSERT 매크로는 정보 메시지를 구성하는 선택적 printf 스타일 형식 문자열과 인수를 취합니다.
BinaryNumber 클래스에서 '=' 연산자를 구현했으므로 다음과 같이 구성할 수 있습니다. 다음 항목은 샘플 파일 BinaryNumberTest.cpp에 있습니다.
BEGIN_TEST(
  BinaryNumberConstructorsShouldBeEquivalent)
{
  BinaryNumber bn1(7);
  BinaryNumber bn2("111");
  WIN_ASSERT_EQUAL(bn1, bn2);
}
END_TEST
또 다른 Assert 유형으로 WIN_ASSERT_THROWS가 있습니다. 프로덕션 코드에서 오류 처리 시 예외를 사용하는 경우 적절한 예외가 발생했는지 확인하고자 테스트에서 오류 조건을 강제로 구현하려고 합니다. 다음과 같이 테스트할 수 있습니다.
BEGIN_TEST(
  BinaryNumberPlusRecognizesIntegerOverflow)
{
  unsigned short s = USHRT_MAX / 2 + 1;
  
    BinaryNumber bn1(s);
    BinaryNumber bn2(s);
  
    WIN_ASSERT_THROWS(bn1 + bn2, 
      BinaryNumber::IntegerOverflowException)
  }
  END_TEST
BinaryNumber 클래스는 부호 없는 short만 보유할 수 있으며 너무 큰 두 숫자를 더하려고 하면 '+' 연산자에서 이를 감지해야 합니다. WIN_ASSERT_THROWS는 예외 유형과 함께 C++ 예외를 발생시켜야 하는 식을 포함합니다. 예외가 발생하지 않으면 테스트가 실패한 것입니다.
그림 4에는 WinUnit.h에서 사용할 수 있는 WIN_ASSERT 매크로와 Assert가 아닌 WIN_TRACE가 나와 있습니다. WIN_TRACE를 사용하면 테스트에서 OutputDebugString API 함수를 통해 추적 기능을 제공할 수 있습니다.

매크로설명
WIN_ASSERT_EQUAL(expected, actual, ...) ==를 사용하여 expected 및 actual 값을 비교합니다. 서로 같지 않으면 실패합니다.
WIN_ASSERT_NOT_EQUAL(notExpected, actual, ...) !=를 사용하여 notExpected 및 actual 값을 비교합니다. 서로 같지 않으면 실패합니다.
WIN_ASSERT_STRING_EQUAL(expected, actual, ...) 대/소문자를 구분하여 expected 값과 actual 값을 비교합니다. 비교할 문자열은 와이드 문자여도 되고 아니어도 되지만 두 값에서 "와이드 문자인지 여부"는 일관되어야 합니다. 선택적 메시지의 경우 "와이드 문자인지 여부"는 아무런 상관이 없습니다.
WIN_ASSERT_ZERO(zeroExpression, ...) zeroExpression과 0을 비교합니다. 같지 않으면 실패합니다.
WIN_ASSERT_NOT_ZERO(nonzeroExpression, ...) nonzeroExpression과 0을 비교합니다. 같으면 실패합니다.
WIN_ASSERT_NULL(nullExpression, ...) nullExpression과 NULL을 비교합니다. 같지 않으면 실패합니다. 포인터에만 적용됩니다.
WIN_ASSERT_NOT_NULL(notNullExpression, ...) notNullExpression과 NULL을 비교합니다. 같으면 실패합니다. 포인터에만 적용됩니다.
WIN_ASSERT_FAIL(message, ...) 항상 실패합니다. 정보 메시지가 요청됩니다.
WIN_ASSERT_TRUE(trueExpression, ...) trueExpression이 true이면 성공합니다.
WIN_ASSERT_FALSE(falseExpression, ...) !falseExpression이 true이면 성공합니다.
WIN_ASSERT_WINAPI_SUCCESS(trueExpression, ...) trueExpression가 true이면 성공합니다. 해당 설명서에서 실패 시 추가 오류 정보를 표시하기 위해 GetLastError를 호출하도록 명시된 Windows 함수와 함께 이 매크로를 사용합니다. 이 매크로/함수는 GetLastError를 호출하고 메시지의 한 부분에 오류 코드와 연관된 문자열을 포함합니다.
WIN_ASSERT_THROWS(expression, exceptionType, ...) 식에서 exceptionType 유형의 C++ 예외가 발생하면 성공합니다.
WIN_TRACE(message, ...) 디버깅 목적으로 정보 메시지를 출력하는 데 사용합니다. 이 매크로를 사용해도 테스트는 실패하지 않습니다. 여기서 "message"는 인수 앞에 나오는 printf 스타일 형식의 문자열입니다.
숫자 리터럴 정수는 항상 int와 서식이 일치하므로 WIN_ ASSERT_EQUAL 또는 WIN_ASSERT_NOT_EQUAL에서 숫자 리터럴 값과 부호 없는 값을 전달하면 하나는 부호 있는 값이고 다른 하나는 부호 없는 값이므로 결과가 서로 일치하지 않습니다. 이때 후위 숫자 리터럴로 'U'를 사용하면 부호 없는 값으로 모두 일치하므로 이 문제를 막을 수 있습니다.
모든 Assert는 선택적으로 printf 스타일 형식의 문자열과 인수를 취합니다. _UNICODE가 정의된 경우 메시지 문자열은 wchar_t*에 해당하므로 형식 문자열을 _T ("") 매크로 안에 넣거나 유니코드만 빌드하는 경우 L""로 묶습니다.
현재 테스트 이름을 보면 테스트할 클래스 이름으로 시작하여 테스트할 메서드 이름이나 설명, 테스트에서 표시할 내용을 설명하는 간단한 문장으로 구성되어 있습니다. 이와 같이 이름을 지정한 데에는 두 가지 목적이 있습니다. 첫 번째 목적은 테스트할 내용을 명확히 표현하려는 것입니다. 그러면 테스트 출력을 검토하는 데 큰 도움이 됩니다. 두 번째 목적은 다양한 수준으로 테스트를 묶어 실행하기 위함입니다. WinUnit는 .NET Framework 기반과는 달리 원래 테스트 그룹 개념을 지원하지 않습니다. 대신 WinUnit는 -p (prefix) 옵션을 통해 프로젝트에서 해당하는 하위 집합 테스트를 실행합니다. 이 옵션에서 prefix 문자열을 지정하면 해당 문자열로 시작하는 이름의 테스트가 모두 실행됩니다. 테스트 특징을 점차 자세히 기술하는 단어로 테스트 이름을 정하면 명령줄에서 관련 테스트 그룹을 쉽게 실행할 수 있습니다.

Fixture: 설정 및 정리
실제로 테스트하려는 대상은 지금까지 살펴본 예제처럼 단순하지만은 않습니다. 그래서 실제로 확인하려는 기능을 실행하려면 여러 설정 단계를 거쳐야 할 수도 있습니다. 예를 들어 디렉터리의 모든 파일을 삭제하는 기능을 테스트하려면 먼저 디렉터리를 만들고 디렉터리에 파일을 넣어야 합니다. 그렇지 않으면 테스트하려는 기능에 필요한 환경 변수를 설정해야 할 수도 있습니다.
테스트의 독립성 및 반복성을 유지하려면 테스트를 시작할 때 설정한 모든 작업은 테스트를 마칠 때 원래대로 되돌려놓아야 합니다. WinUnit는 단일 테스트라는 fixture 개념을 지원합니다. setup과 teardown 함수 쌍은 연관된 모든 테스트 시작과 종료 시점에서 실행됩니다. 특히 설정 및 정리 작업이 같아야 하는 여러 테스트를 수행하는 경우 유용합니다.
그림 5에는 setup과 teardown 함수가 구현되어 있으며 이들 함수와 연관된 두 개의 테스트를 포함하는 fixture 예제가 나와 있습니다. 이 예제를 보면 정의한 fixture가 제대로 작동하지 않을 경우 실패를 보고하도록 다양한 WIN_ASSERT 매크로를 사용하고 있습니다.
//  DeleteFileTest.cpp

#include "WinUnit.h"

#include <windows.h>

// Fixture를 선언해야 합니다.
FIXTURE(DeleteFileFixture);

namespace
{
    TCHAR s_tempFileName[MAX_PATH] = _T("");
    bool IsFileValid(TCHAR* fileName);
}

// SETUP과 TEARDOWN 모두 있어야 합니다.  
SETUP(DeleteFileFixture)
{
    // GetTempFileName에 전달된 디렉터리의 최대 크기입니다.
    const unsigned int maxTempPath = MAX_PATH - 14; 
    TCHAR tempPath[maxTempPath + 1] = _T("");
    DWORD charsWritten = GetTempPath(maxTempPath + 1, tempPath);
    // (charsWritten은 널 문자를 포함하지 않습니다.)
    WIN_ASSERT_TRUE(charsWritten <= maxTempPath && charsWritten > 0, 
        _T("GetTempPath failed."));

    // 임시 파일을 작성합니다.
    UINT tempFileNumber = GetTempFileName(tempPath, _T("WUT"), 
        0, // 파일이 생성되어 닫혔음을 의미합니다.
        s_tempFileName);

    // 실제로 파일이 있어야 합니다.
    WIN_ASSERT_WINAPI_SUCCESS(IsFileValid(s_tempFileName), 
        _T("File %s is invalid or does not exist."), s_tempFileName);
}

// TEARDOWN은 SETUP 작업을 되돌리며 테스트에서 
// 발생할 수 있는 모든 부작용을 취소합니다.
TEARDOWN(DeleteFileFixture)
{
    // 임시 파일이 있으면 삭제합니다.
    if (IsFileValid(s_tempFileName))
    {
        // 파일은 읽기 전용이 아니어야 합니다.
        DWORD fileAttributes = GetFileAttributes(s_tempFileName);
        if (fileAttributes & FILE_ATTRIBUTE_READONLY)
        {
            WIN_ASSERT_WINAPI_SUCCESS(
                SetFileAttributes(s_tempFileName, 
                    fileAttributes ^ FILE_ATTRIBUTE_READONLY),
                    _T("Unable to undo read-only attribute of file %s."),
                    s_tempFileName);
        }

        // DeleteFile을 테스트하고 있으므로 정리 시 
        // 다른 CRT 파일 삭제 함수를 사용합니다.
        WIN_ASSERT_ZERO(_tremove(s_tempFileName), 
            _T("Unable to delete file %s."), s_tempFileName);
    }

    // 임시 파일 이름을 지웁니다.
    ZeroMemory(s_tempFileName, 
        ARRAYSIZE(s_tempFileName) * sizeof(s_tempFileName[0]));
}

BEGIN_TESTF(DeleteFileShouldDeleteFileIfNotReadOnly, DeleteFileFixture)
{
    WIN_ASSERT_WINAPI_SUCCESS(DeleteFile(s_tempFileName));
    WIN_ASSERT_FALSE(IsFileValid(s_tempFileName), 
        _T("DeleteFile did not delete %s correctly."),
        s_tempFileName);
}
END_TESTF

BEGIN_TESTF(DeleteFileShouldFailIfFileIsReadOnly, DeleteFileFixture)
{
    // 파일을 읽기 전용으로 설정합니다.
    DWORD fileAttributes = GetFileAttributes(s_tempFileName);
    WIN_ASSERT_WINAPI_SUCCESS(
        SetFileAttributes(s_tempFileName, 
        fileAttributes | FILE_ATTRIBUTE_READONLY));

    // 스펙에 따라 DeleteFile이 ERROR_ACCESS_DENIED로 실패했는지 확인합니다.
    WIN_ASSERT_FALSE(DeleteFile(s_tempFileName));
    WIN_ASSERT_EQUAL(ERROR_ACCESS_DENIED, GetLastError());
}
END_TESTF

namespace
{
    bool IsFileValid(TCHAR* fileName)
    {
        return (GetFileAttributes(fileName) != INVALID_FILE_ATTRIBUTES);
    }
}
(참고: 프로그래머 주석은 예제 프로그램 파일에는 영문으로 제공되며 기사에는 이해를 돕기 위해 번역문으로 제공됩니다.)
이 예제에서는 Windows DeleteFile 함수의 작성자로 가정하고 몇 가지 테스트 함수를 작성하려고 합니다. setup 함수에서 먼저 임시 파일을 만듭니다. 이 테스트 함수에서는 임시 파일을 삭제하여 DeleteFile 기능을 시험합니다. 테스트 중 하나가 끝나면 파일이 삭제되겠지만 아직 파일이 있는지와 teardown 함수에서 삭제되었는지 확인하는 절차가 남았습니다. teardown은 setup에서 어떤 작업을 수행했건 모두 원래대로 되돌려야 하며 다음 테스트는 고려하지 않습니다. 그러면 테스트의 독립성과 반복성을 유지할 수 있습니다.
여기서도 알 수 있듯이 테스트에서 fixture를 사용하는 구문은 조금씩 다릅니다. 여기서는 BEGIN_TEST 및 END_TEST를 구성하는 대신, BEGIN_TESTF 및 END_TESTF가 사용되었습니다. 이때 fixture 이름은 BEGIN_TESTF의 테스트 이름 뒤에 나옵니다. setup과 teardown 함수는 SETUP 및 TEARDOWN(모두 대문자)과 괄호 안에 든 fixture 이름으로 표시됩니다.
이 예제에서는 WIN_ASSERT_WINAPI_SUCCESS 매크로를 여러 번 사용하고 있습니다. 그림 4와 같이 이 매크로는 Windows 함수와 함께 사용합니다. 이 함수 관련 설명서에는 실패한 경우 자세한 내용을 표시하기 위해 GetLastError를 호출하도록 명시되어 있습니다. Assert의 첫 번째 매개 변수는 함수 호출과 관련하여 참으로 예상되는 문이어야 합니다. 부울을 반환하는 경우 함수 자체일 수도 있습니다. 함수가 식의 일부가 아닌 경우 정확히 어떤 상황이 벌어졌는지 출력에서 바로 쉽게 확인할 수 있도록 이 Assert이 시작되면 실패하는 함수 이름을 표시하는 메시지를 포함하는 것이 좋습니다. Assert 구현은 GetLastError를 호출하고 오류 코드와 연관된 메시지 문자열을 검색하여 테스트 결과에 추가합니다.
fixture를 사용하는 다른 방법은 TestSampleLib 프로젝트의 BinaryNumberTest.cpp를 참조하십시오. 여기서는 테스트에 사용할 읽을 테스트 데이터의 행을 보유한 데이터 공급자를 열고 닫는 데 사용했습니다. 이때 데이터 공급자는 텍스트 파일로, 각 행은 하나의 행으로 구성됩니다. 하지만 XML 파일 또는 데이터베이스 테이블일 수도 있습니다. 테스트에서 다루는 데이터 양이 많은 경우 제 기능을 발휘하는 기능을 테스트할 때도 비슷한 접근 방식을 사용할 수 있습니다.
WinUnit는 다른 단위 테스트 프레임워크와는 달리 fixture가 테스트와 자동으로 연관되지 않습니다. 그래서 명확히 연관시킬 필요가 있습니다. 이러한 특징 때문에 한 파일에 둘 이상의 fixture를 보유할 수도 있습니다.

WinUnit 실행
이제 WinUnit가 작동하는 방법을 살펴보겠습니다. WinUnit는 Windows에서 네이티브 코드에 사용할 수 있는 리플렉션과 유사한 기능 중 하나만 사용합니다. 예를 들어 내보낸 DLL을 탐지하는 기능이 이에 해당합니다. 이와 관련하여 2002년 2월과 3월, 두 차례에 걸쳐 MSDN® Magazine에 연속 기재된 Matt Pietrek의 "Inside Windows: An In-Depth Look into the Win32® Portable Executable File Format" 기사가 있는데, DDL 내보내기를 탐지하는 방법을 구현할 때 이 기사 내용을 많이 참조하였습니다. 이 방법은 32비트와 64비트 실행 파일에서 모두 사용할 수 있지만 WinUnit 64비트 빌드에서 64비트 테스트 바이너리를 실행하십시오. 이때 32비트 실행 파일은 64비트 파일을 실행할 수 없습니다.
WinUnit의 명령줄 구문은 다음과 같습니다.
WinUnit [options] {dllName | directoryName}+
도구는 포함된 모든 DLL을 열거할 하나 이상의 DLL 또는 디렉터리를 취합니다. 내보낸 DLL을 찾아서 이름이 데코레이트되지 않았으며 TEST_로 시작하는 항목을 실행합니다. 함수 프로토타입이 가정되기 때문에 테스트로 실행하려는 함수만 실행하도록 이 제한 사항이 설정된 것입니다. 예상되는 함수 프로토타입은 다음과 같습니다.
bool __cdecl TEST_TestName(wchar_t* errorBuffer, size_t bufferSize);
errorBuffer 매개 변수는 테스트에서 오류 출력을 받으며 bufferSize는 와이드 문자의 버퍼 크기를 나타냅니다. 테스트 함수가 false를 반환하거나 SHE(구조적 예외 처리) 예외가 발생한 경우 테스트는 실패한 것으로 간주하며 errorBuffer에 전송된 출력이 표시됩니다.
DLL과 디렉터리 이름 이외에도 WinUnit는 여러 명령줄 인수 옵션을 사용합니다(그림 6 참조). 기본적으로 WinUnit 출력은 콘솔로, 정보 메시지는 stdout로, 오류 메시지는 stderr로 전달됩니다. WinUnit가 지정된 DLL을 처리한 후 성공했으면 코드 0, 그렇지 않으면 0이 아닌 값으로 종료됩니다(그림 7 참조).

코드설명
0 오류 없음(실행한 테스트 모두 성공)
1 하나 이상의 테스트 실패
2 처리되지 않은 예외로 도중에 중단됨
-1 사용 오류

매개 변수설명
-q 콘솔에 출력하지 않습니다.
-n 비대화형 UI입니다. 가능한 경우 오류 대화 상자는 사용하지 않습니다. 자동화된 빌드에서 권장됩니다.
-b 디버거에 출력합니다. 출력은 기본적으로 콘솔로만 전달됩니다.
-p string 접두사 string으로 시작하는 테스트만 실행합니다(대/소문자 구분).
-x 내보내기의 TEST_ prefix 요구 사항을 무시합니다. 이름에 관계없이 모든 내보내기를 처리합니다. 이 옵션은 잘못된 함수 프로토타입으로 함수를 실행하지 않도록 방지하기 위해 -s 옵션과 함께 사용할 때만 사용할 수 있습니다.
-s 테스트 이름만 표시합니다. 기본적으로 테스트를 실행합니다.
-o filename 지정된 파일에 출력을 넣습니다. -q 및 -b 옵션과는 무관합니다. 출력은 콘솔, 파일 또는 디버거 중 어디로도 전달할 수 있습니다.
-v number 지정된 상세 수준으로 실행합니다. 기본값은 1입니다. 값이 0이면 실패한 테스트와 전체 결과만 표시됩니다.
-l customLoggerString 사용자 지정 로거 문자열은 사용자 지정 로거를 구현한 DLL 이름과 선택적으로 콜론과 로거의 Initialize 함수에 전달할 콜론과 초기화 문자열로 구성됩니다.
--variable value 이 옵션은 환경 변수(variable)를 WinUnit 프로세스 수명에 대한 값(value)으로 설정합니다. 명령줄을 통해 테스트에 데이터를 전달할 때 사용할 수 있습니다.
-e ( exactTestName* ) (참고: 정확히 일치하는 항목으로 제한하기 위해 이 옵션이 포함되지만 원래 목적은 자동화를 지원하기 위해서입니다. 보통은 –p 옵션을 사용합니다.) 전체 이름으로 지정된 테스트만 실행합니다. 이때 대/소문자를 구분하며 공백으로 각 테스트를 분리하고 괄호로 묶습니다.
예제에서 사용한 BEGIN_TEST 매크로는 테스트 이름 앞에 TEST_를 붙이고 'extern "C" __declspec(dllexport)'를 사용하여 데코레이트되지 않은 함수 이름을 내보냅니다. DLL에 대한 .def 파일에 삽입하는 방법과 같습니다.
WIN_ASSERT 매크로는 C++ 예외를 통해 작동하지만 모든 예외는 테스트 함수 자체에서 발생하므로 WinUnit는 예외의 사용 여부에 영향을 받지 않습니다. 예외를 사용한 이유는 어떤 지점에서도 쉽게 종료할 수 있고 적절히 정리되었는지 확인할 수 있어서입니다. 앞서 보았던 함수 프로토타입을 사용하여 매크로 없이도 WinUnit에서 제대로 작동하는 테스트 함수를 구현하면 이름이 TEST_로 시작되도록 하고 DLL에서 데코레이트되지 않은 양식으로 내보낼 수 있습니다.
BEGIN_TEST 및 END_TEST 매크로는 함수를 선언하고 try/catch 블록을 설정합니다. WIN_ASSERT 매크로는 실패 시 오류를 설명하는 메시지 문자열을 포함하는 AssertException 클래스 예외를 발생시킵니다. 이 AssertException을 감지하면 메시지를 버퍼에 복사하고 함수에서 false를 반환합니다.

세부 구현
처음에는 테스트 함수에 대한 매개 변수를 지정하지 않고 대신 함수에서 직접 예외를 발생시켜 테스트 실행기에서 이 예외를 감지하여 처리하면 세련된 함수라고 생각했습니다. 그러면 END_TEST 매크로가 필요하지 않습니다. 그러나 DLL 경계에서 예외가 발생한 경우 스택이 제대로 해제되지 않으므로 세부 구현으로 예외를 발생시켜 테스트 자체 내에서 예외를 감지하는 편이 더 낫다는 결론을 내렸습니다.
fixture 개념을 구현하는 작업도 쉽지만은 않았습니다. 처음에는 C++ 자동 저장 기능에 의존하여 생성자와 소멸자가 설정 및 정리를 수행하는 테스트 함수 맨 위에서 개체를 선언하는 방식을 구상했지만 곧 소멸자에서 예외를 발생시키면 안 되는 이유를 깨달았습니다. C++ 스펙 일부에서는 스택 해제 중 소멸자 함수에서 예외가 발생했을 때 종료 함수를 호출한다고 명시되어 있습니다. 이 상태는 설명한 대로 fixture를 구현하고 해당 소멸자에서 예외를 발생시키는 Assert를 포함한 fixture 개체를 보유하는 경우에 발생할 수 있습니다. 함수 본문에서 예외가 발생하여 스택 해제 중 fixture 개체의 소멸자에서 Assert가 시작되면 종료 함수가 호출됩니다.
이 문제를 해결하려면 fixture 개체의 소멸자를 함수의 주 try/catch 블록 외부에 있는 고유한 try/catch 블록에 단독으로 삽입하여 스택 해제 도중에 호출되지 않도록 해야 합니다. BEGIN_TESTF와 END_TESTF 쌍을 사용하는 특별한 fixture 구문을 통해 두 개의 try/catch 블록과 fixture 개체 선언을 포함해야 합니다.
이처럼 테스트 함수가 이상 종료되자 다른 조치가 필요하다고 생각했습니다. 그래서 비대화형 모드에서 실행하는 옵션(현재 –n 명령줄 옵션)을 제공하고자 했습니다. 일반적으로 WinUnit는 비대화형 모드로 실행되지만 종료 함수가 호출되면 대화 상자를 표시합니다. 자동화된 시나리오에서는 잠재적으로 자동화된 작업을 추가로 차단할 수 있으므로 대화 상자가 오히려 걸림돌이 될 수 있습니다.
WinUnit 프로젝트의 ErrorHandler.cpp를 보면 이러한 오류 메시지를 표시하지 않도록 설정한 내용을 확인할 수 있습니다. 한 가지 주목할 사항은 오류 조건에서 나타날 수 있는 일부 대화 상자가 C 런타임(CRT)과 연관되었다는 점입니다. 즉, 테스트 실행 도구(WinUnit)에 전역 변수를 설정했지만 테스트 바이너리에서 다른 CRT를 사용하면 원래 CRT에서 해당 변수를 해제해도 아무런 효과가 없습니다. 각 CRT에 연관된 오류 대화 상자를 해제하려면 WinUnit와 테스트 바이너리 모두를 빌드할 때 같은 런타임 라이브러리 옵션을 사용해야 하며 이 옵션은 DLL 옵션 중 하나여야 합니다. 이 옵션은 속성 | 구성 속성 | C/C++ | 코드 생성에 있습니다. 기본적으로 릴리스 빌드의 경우에는 /MD, 디버그 빌드의 경우에는 /MDd입니다. Visual Studio 2005에서 여기에 나온 같은 옵션으로 WinUnit와 테스트 바이너리 모두를 빌드하는 경우 CRT 인스턴스는 같습니다.
그리고 디버깅 테스트를 지원하는 몇 가지 추적 기능을 추가했습니다. WinUnit는 Windows에서 실행된다는 점과 PE 형식에 대해 알고 있다는 점을 활용하기로 했습니다. 이를 위해 실행할 테스트 DLL에서 OutputDebugStringA와 OutputDebugStringW를 연결(재정의)하여 원하는 위치에 출력을 전송합니다. 여기서 많은 부분은 John Robbins의 저서 Debugging Applications for Microsoft .NET and Microsoft Windows(Microsoft Press®, 2003)에 나온 코드를 저자 허락 하에 차용했습니다. 그 다음 OutputDebugString에 해당 인수를 분산시키는 WIN_TRACE 매크로를 제공했습니다.
또한 명령줄에서 테스트로 정보를 전달하고자 했습니다. 그러면 자동화된 빌드의 일부분으로 도구를 사용할 때 테스트 데이터 파일이 있는 알려진 위치가 있거나 테스트를 통해 해당 위치를 확인하려는 경우에 도움이 될 수 있습니다. 그래서 환경 변수를 사용하기로 결정했습니다. "--" 명령줄 옵션을 사용하면 프로세스에서 임의의 변수를 설정하고 WinUnit::Environment::GetString(a wrapper on GetEnvironmentVariable), GetEnvironmentVariable 자체 또는 getenv_s/_wgetenv_s를 사용하여 테스트 내에서 검색할 수 있습니다.
마지막으로 사용자에게 사용자 지정 로거를 작성하는 기능을 제공하고자 했습니다. 내부적으로는 로거 체인을 사용합니다. 그러면 콘솔, OutputDebugString, 파일 또는 이들로 구성된 임의의 조합 중에서 출력 전송 위치를 선택할 수 있습니다. 네 번째 옵션은 다음에 설명할 사용자 지정 로거를 제공할 때 필요합니다.

추가 작업
WinUnit는 기본적인 네이티브 C++ 단위 테스트 요구에 부응해야 하지만 이외에도 몇 가지 다른 기능이 있습니다.
사용자 지정 로거를 전달할 때 –l 명령줄 옵션을 사용합니다(그림 6 참조). 이 기능은 WinUnit 출력을 XML 파일로 전송하거나 보고 시스템에서 사용하기 위해 데이터베이스로 전송할 때 사용할 수 있습니다. 또한 기본 로거는 유니코드를 작성하지 않습니다. ANSI 텍스트 파일을 작성하려면 –o 옵션을 사용합니다. 유니코드로 출력을 표시하려면 사용자 지정 로거를 작성해야 합니다. 사용자 지정 로거 기능을 구현하려면 10개의 로거와 관련된 함수 집합 하나 이상을 구현하는 DLL을 만들어 –l 옵션을 통해 DLL에 대한 경로를 WinUnit에 전달하면 됩니다. 자세한 내용은 SampleLogger.cpp 프로젝트를 참조하십시오.
예제와 같이 기존 Assert를 사용하여 사용자 지정 Assert를 추가할 수도 있습니다. 테스트 DLL에 연결할 추가 헤더 파일 또는 정적 라이브러리를 만들 수도 있습니다. 더 세분화된 문자열 Assert 집합을 구현하는 방법도 있습니다. 이 방법은 .NET 기반 코드를 위한 Visual Studio 단위 테스트 프레임워크에서 제공하는 StringAssert 클래스와 비슷합니다. 여기에는 정규식을 사용한 문자열 비교도 포함됩니다. 전체 파일 또는 파일 특성을 비교하는 FileAssert 클래스도 도움이 됩니다. Assert를 구현할 때에는 정적 메서드와 함께 Assert 클래스를 사용했으며 WIN_ASSERT 매크로가 이들을 감싸는 씬 래퍼에 해당합니다. 매크로를 전혀 사용하지 않는 편이 좋았을 수도 있지만 파일과 행 번호를 쉽게 포함하고 싶어서 사용한 것입니다. 물론 매크로는 전역 네임스페이스에 혼란을 가중시키는 측면이 있으므로 직접 작성할 때는 앞에 다른 접두사를 추가하는 것도 좋은 방법입니다.
Visual Studio의 또 다른 편리한 추가 기능은 코드 검사와는 상관없이 해당 테스트를 개별적으로 실행하는 기능으로 테스트에서 마우스 오른쪽 단추를 클릭하면 사용할 수 있습니다. 이 기능은 .NET Framework에서 TestDriven.NET이 단위 테스트를 수행하는 방법과 비슷합니다. 코드 검사 도구에 대한 내용은 보충 기사 "코드 검사와 편리함"을 참조하십시오.
WinUnit를 만든 이유는 네이티브 C++ 단위 테스트 작업이 쉽고 재미있으며 지금 바로 시작할 수 있음을 증명하기 위해서였습니다. WinUnit의 강점을 제대로 이해하려면 WinUnit를 테스트하는 데 사용한 TestWinUnit 프로젝트를 검토해보십시오. 여기 나온 예제는 모두 실제로 사용한 것이며 사용자의 단위 테스트에 적용할 수 있는 고급 사용 방법을 보여주고 있습니다. 네이티브 C++ 단위 테스트가 어렵게 느껴진다면 WinUnit를 사용해보십시오. 언제라도 쉽게 테스트할 수 있으며 실제로도 정말 테스트가 쉬워집니다.

Maria Blees는 Microsoft에서 10년간 근무한 개발자로, 네이티브 코드에 특별한 애착을 가지고 있으며 엔지니어링에 뛰어난 기량을 보이고 있습니다. 버그 보고서와 이 도구에 대한 제안이 있으면 코드와 함께 제공된 주소로 보내주십시오.
7

반응형

댓글