[C#] Unit 테스트를 하는 방법(MSTest)


Development note/C#  2020. 12. 21. 13:36

안녕하세요. 명월입니다.


이 글은 C#에서 Unit 테스트를 하는 방법(MSTest)에 대한 글입니다.


우리가 프로그램을 CI(Continuous Integration: 지속적인 통합)툴을 이용한 소스 통합을 할 때, 프로그램 자체적인 프로그램 소스 테스트가 필요할 때가 있습니다.

Java의 경우는 JUnit 라이브러리가 있고, C#에서는 MSTest 라이브러리가 있습니다. 두 라이브러리가 문법에 차이는 약간 있지만 방법에 관해서는 거의 비슷하기 때문에 JUnit의 사양을 아는 분에게는 거의 부담감 없이 사용할 수 있을 것 같습니다.


먼저, 간단한 덧셈, 뺄셈하는 프로그램을 작성하겠습니다.

using System;

namespace Example
{
  // 계산 타입
  public enum CalType
  {
    ADD,
    SUBTRACT
  }
  // Program 클래스 (접근 제한자는 public이 아니다. 즉, 같은 네임스페이스에서만 사용 가능)
  class Program
  {
    // 맴버 변수
    private int ret = 0;
    // 생성자 (파라미터는 두 개의 숫자로 된 string 값과 계산 타입을 받는다.)
    public Program(string a, string b, CalType type)
    {
      // string을 int형으로 변환한다.
      int aa = int.Parse(a);
      int bb = int.Parse(b);
      // 계산 타입에 따라 덧셈으로 계산한다.
      if (CalType.ADD.Equals(type))
      {
        // 덧셈 함수 호출, 결과를 맴버 필드에 저장
        ret = Add(aa, bb);
      }
      // 계산 타입에 따라 뺄셈으로 계산한다.
      else if (CalType.SUBTRACT.Equals(type))
      {
        // 뺄셈 함수 호출, 결과를 맴버 필드에 저장
        ret = Subtract(aa, bb);
      }
    }
    // 덧셈 함수
    private int Add(int a, int b)
    {
      // 두 개의 파라미터를 더한다.
      return a + b;
    }
    // 뺄셈 함수
    private int Subtract(int a, int b)
    {
      // 두 개의 파라미터를 뺀다.
      return a - b;
    }
    // 맴버 필드를 출력한다.
    public int ToResult()
    {
      return this.ret;
    }
    // 메인 함수
    static void Main(string[] args)
    {
      // 파라미터 변수를 3개를 받지 않으면 에러 발생
      if (args.Length != 3)
      {
        // Exception 발생
        throw new Exception("The usage is wrong.");
      }
      // 세번째 파라미터는 계산 타입
      CalType? type = null;
      // Add의 문자라면 덧셈을
      if (CalType.ADD.ToString().Equals(args[2], StringComparison.OrdinalIgnoreCase))
      {
        // 타입 설정
        type = CalType.ADD;
      }
      // Subtract의 문자라면 뺄셈을
      else if (CalType.SUBTRACT.ToString().Equals(args[2], StringComparison.OrdinalIgnoreCase))
      {
        // 타입 설정
        type = CalType.SUBTRACT;
      }
      // 그 외의 문자는 에러를
      else
      {
        // Exception 발생
        throw new Exception("The usage is wrong.");
      }
      // 생성자를 생성하고
      var p = new Program(args[0], args[1], type.Value);
      // 결과를 콘솔에 출력한다.
      Console.WriteLine($"Result - {p.ToResult()}");
    }
  }
}

위 소스는 단순하게 3개의 파라미터를 받고 덧뺄셈하는 아주 심플한 콘솔 프로그램입니다.

이걸 Visual Studio에서 실행하기 위해서는 디버그 옵션에 3개의 옵션을 넣어야겠네요.

그리고 디버깅 결과입니다.

프로그램 자체는 아주 심플합니다만 우리는 이걸 MSTest를 이용해서 Unit Test하는 라이브러리를 만들어 보겠습니다.

프로그램 프로젝트에 새로운 프로젝트를 생성합니다.


그럼 검색 창에 test를 치면 .Core용 테스트 프로젝트와 .Net Framework용 프로젝트가 있는데, 저는 .Net Framework로 프로젝트를 만들었으니 .Net Framework 용으로 프로젝트를 생성합니다.

그리고 클래스 명은 정해진 것은 없지만 보통 테스트할 클래스의 어미에 Tests를 붙여서 클래스명을 설정합니다.

여기서 Unit 테스트할 클래스와 함수를 만들어 보겠습니다.

using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Example;
using System.Reflection;
using System.Linq;

namespace ExampleTest
{
  [TestClass]
  public class ProgramTests
  {
    // Reflection으로 가져올 타입 설정
    private static BindingFlags ALLBIND = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
    // 라이브러리 및 실행 파일을 동적으로 읽어 올 클래스
    private readonly Assembly ASSEMBLY;
    // 클래스 typeof
    private readonly Type PROGRAM_TYPE;
    // 메소드 종류
    private readonly MethodInfo[] PROGRAM_METHODS;
    // 맴버 필드 종류
    private readonly FieldInfo[] PROGRAM_FIELDS;

    // 생성자
    public ProgramTests()
    {
      // 라이브러리 및 실행 파일을 읽어 온다.
      ASSEMBLY = Assembly.LoadFrom("Example.exe");
      // 해당 파일에서 Program 클래스를 읽어 온다.
      PROGRAM_TYPE = ASSEMBLY.GetType("Example.Program");
      // 메소드를 읽어 온다.
      PROGRAM_METHODS = PROGRAM_TYPE.GetMethods(ALLBIND);
      // 맴버 필드를 읽어 온다.
      PROGRAM_FIELDS = PROGRAM_TYPE.GetFields(ALLBIND);
    }
    // 인스턴스를 생성하는 함수
    private object NewProgram(int a, int b, CalType type)
    {
      // 접근 제한자의 관계없이 파라미터가 세개인 생성자를 찾는다.
      var construnct = PROGRAM_TYPE.GetConstructors(ALLBIND).Where(x => x.GetParameters().Length == 3).SingleOrDefault();
      // 없으면 에러
      if (construnct == null)
      {
        throw new NotImplementedException();
      }
      // 생성자를 실행하여 인스턴스를 생성한다.
      return construnct.Invoke(new object[] { a.ToString(), b.ToString(), type });
    }
    // 메소드를 읽어 오는 함수(GetMethods의 함수로 읽어 올 수 있으나 정확성과 편의성을 위해 만들었다.)
    private MethodInfo GetProgramMethods(string name, params Type[] types)
    {
      // 메소드 이름과 파라미터의 개수가 같은 것을 취득한다.
      var list = PROGRAM_METHODS.Where(x => x.Name.Equals(name) && x.GetParameters().Length == types.Length).ToList();
      if (types.Length > 0)
      {
        // 파라미터의 순서와 타입이 맞는지 체크한다.
        Func<MethodInfo, bool> check = (m) =>
        {
          for (var i = 0; i < types.Length; i++)
          {
            // 파라미터의 이름이 같은 것인지 확인한다. FullName인 경우, 네임스페이스 및 클래스까지 같은 것인지 확인하는 것
            if (!String.Equals(types[i].FullName, m.GetParameters()[i].ParameterType.FullName))
            {
              return false;
            }
          }
          return true;
        };
        // 검색된 메소드가 복수일 경우
        foreach (var m in list)
        {
          // 파라미터 순서와 타입이 맞는 것으로 리턴한다.
          if (check(m))
          {
            return m;
          }
        }
      }
      // 파라미터가 없을 경우.
      else
      {
        return list.SingleOrDefault();
      }
      return null;
    }
    // 맴버 변수를 취득하는 함수
    private FieldInfo GetProgramFields(string name, params Type[] types)
    {
      // 이름이 같은 것을 취득한다.
      return PROGRAM_FIELDS.Where(x => x.Name.Equals(name)).SingleOrDefault();
    }

    // 테스트 카테고리 설정(Jenkins 나 Github에서 카테고리 별 테스트 설정할 때 사용하는 옵션)
    [TestCategory("Program")]
    // 검사할 데이터 설정
    [DataRow(1, 2, 3)]
    // Unit 테스트 함수 설정
    [TestMethod]
    public void AddTest(int a, int b, int c)
    {
      // 인스턴스를 생성한다.
      var instance = NewProgram(a, b, CalType.ADD);
      // Add 함수를 가져와서 실행해서 확인한다.
      Assert.AreEqual(c, GetProgramMethods("Add", typeof(int), typeof(int)).Invoke(instance, new object[] { a, b }), "Method calculation");
      // 맴버 변수를 확인한다.
      Assert.AreEqual(c, GetProgramFields("ret").GetValue(instance), "Program field result");
      // ToResult 함수를 호출하여 나오는 값을 확인한다.
      Assert.AreEqual(c, GetProgramMethods("ToResult").Invoke(instance, null), "ToResult return value");
    }
    // 테스트 카테고리 설정(Jenkins 나 Github에서 카테고리 별 테스트 설정할 때 사용하는 옵션)
    [TestCategory("Program")]
    // 검사할 데이터 설정
    [DataRow(3, 2, 1)]
    // Unit 테스트 함수 설정
    [TestMethod]
    public void SubtractTest(int a, int b, int c)
    {
      // 인스턴스를 생성한다.
      var instance = NewProgram(a, b, CalType.SUBTRACT);
      // Subtract 함수를 가져와서 실행해서 확인한다.
      Assert.AreEqual(c, GetProgramMethods("Subtract", typeof(int), typeof(int)).Invoke(instance, new object[] { a, b }), "Method calculation");
      // 맴버 변수를 확인한다.
      Assert.AreEqual(c, GetProgramFields("ret").GetValue(instance), "Program field result");
      // ToResult 함수를 호출하여 나오는 값을 확인한다.
      Assert.AreEqual(c, GetProgramMethods("ToResult").Invoke(instance, null), "ToResult return value");
    }
    // 테스트 카테고리 설정(Jenkins 나 Github에서 카테고리 별 테스트 설정할 때 사용하는 옵션)
    [TestCategory("Program")]
    // Unit 테스트 함수 설정
    [TestMethod]
    public void MainTest()
    {
      // static 메인 함수를 찾는다.
      var main = PROGRAM_TYPE.GetMethod("Main", BindingFlags.NonPublic | BindingFlags.Static);

      try
      {
        // 파라미터를 넣지 않는다.
        main.Invoke(null, null);
        // 만약 Exception이 발생하지 않으면 에러다.
        Assert.Fail("When the parameter is null, the Exception is not occured.");
      }
      catch (Exception)
      {

      }
      try
      {
        // 세번째 파리미터가 계산 타입이 아닐 경우
        main.Invoke(null, new object[] { new string[] { "1", "2", "3" } });
        // 만약 Exception이 발생하지 않으면 에러다.
        Assert.Fail("When the third parameter is not calculate type, the Exception is not occured.");
      }
      catch (Exception)
      {

      }
      try
      {
        // 정상 덧셈 파라미터를 넣을 경우
        main.Invoke(null, new object[] { new string[] { "1", "2", "Add" } });
      }
      catch (Exception)
      {
        // Exception이 발생하면 에러다.
        Assert.Fail("When the parameter is [1], [2], [Add], the Exception is occured.");
      }
      try
      {
        // 정상 뺄셈 파라미터를 넣을 경우
        main.Invoke(null, new object[] { new string[] { "2", "1", "Subtract" } });
      }
      catch (Exception)
      {
        // Exception이 발생하면 에러다.
        Assert.Fail("When the parameter is [2], [1], [Subtract], the Exception is occured.");
      }
    }
  }
}

소스를 작성하고 소스 탐색기에서 Run Tests를 실행하면 Visual studio에서 TestMethod의 어튜리뷰트가 있는 클래스를 실행하게 됩니다.

위 소스를 보면 제가 생성자에서 Assembly를 사용해서 라이브러리 및 실행 파일을 읽어서 Reflection 기능으로 인스턴스를 생성하고 메소드를 찾습니다.

그 이유는 우리가 프로그램을 만들때 객체 지향의 특성을 이용해서 클래스의 접근 제한자를 설정하고 캡슐화를 시도하게 됩니다.


그런데 Unit 테스트에서 이것을 실행하기 위해서는 접근 제한자를 public으로 바꾸어야 하는 문제가 발생합니다. 또 Console 프로그램은 출력이 dll도 아닌 exe 파일이라 라이브러리 참조도 되지 않는 구조입니다.

MSTest를 위해서 컴파일 타입과 접근 제한자 및 객체 지향의 특성을 바꿀 수가 없기 때문에 Unit 테스트할 때는 Reflection의 기능을 이용해서 접근해야 합니다.


이걸 이번에는 Console에서 실행해 보도록 하겠습니다.

콘솔에서는 vstest.console를 이용해서 테스트하게 되면 실행 결과가 나옵니다. (저는 PC가 일본OS라 일본어로 출력이 됩니다...)


이러면 CI의 Jenkins나 C#의 전용 CI툴인 Teamcity 등을 이용해서 Unit 테스트가 가능하게 됩니다.

(찾아보니 Jenkins에서 MSTest를 사용하려면 전용 패키지를 별도로 설치해야 된다고 설명되어 있네요... 시간나면 한번 구축해 봐야겠습니다.)


참고로 이 vstest.console를 실행하기 위해서는 VisualStudio Path가 연결된 콘솔에서 사용하여야 합니다.

여기까지 C#에서 Unit 테스트를 하는 방법(MSTest)에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.