shim을 사용하여 유닛 테스트를 위한 앱 격리

심 유형은 Microsoft Fakes 프레임워크에서 사용하는 두 가지 주요 기술 중 하나로, 테스트 중에 앱의 구성 요소를 격리하는 데 중요한 역할을 합니다. 이 기술은 호출을 가로채서 특정 메서드로 전환한 다음 테스트 내에서 사용자 지정 코드로 연결할 수 있습니다. 이 기능을 사용하면 이러한 메서드의 결과를 관리하여 외부 조건에 관계없이 각 호출 중에 일관되고 예측 가능한 결과를 보장할 수 있습니다. 이러한 수준의 제어는 테스트 프로세스를 간소화하고 보다 안정적이고 정확한 결과를 얻을 수 있도록 도와줍니다.

솔루션의 일부를 구성하지 않는 코드와 어셈블리 사이에 경계를 만들어야 할 때 를 사용하세요. 솔루션의 구성 요소를 서로 분리하는 것이 목표인 경우 스텁를 사용하는 것이 좋습니다.

(스텁에 대한 자세한 설명은 스텁을 사용하여 단위 테스트를 위해 애플리케이션의 일부를 서로 분리하기를 참조하세요.)

심 제한 사항

심에는 한계가 있다는 점에 유의해야 합니다.

.NET 기본 클래스의 특정 라이브러리, 특히 .NET Framework의 mscorlibSystem, .NET Core 또는 .NET 5+의 System.Runtime의 모든 유형에는 쉼을 사용할 수 없습니다. 성공적이고 효과적인 테스트 전략을 위해 테스트 계획 및 설계 단계에서 이 제약 조건을 고려해야 합니다.

심 만들기: 단계별 가이드

구성 요소에 System.IO.File.ReadAllLines에 대한 호출이 포함된 경우를 가정합니다.

// Code under test:
this.Records = System.IO.File.ReadAllLines(path);

클래스 라이브러리 생성

  1. Visual Studio를 열고 Class Library 프로젝트 생성하기

    Screenshot of NetFramework Class Library project in Visual Studio.

  2. 프로젝트 이름을 HexFileReader로 설정합니다.

  3. 솔루션 이름을 ShimsTutorial로 설정합니다.

  4. 프로젝트의 대상 프레임워크를 .NET Framework 4.8로 설정합니다

  5. 기본 파일 Class1.cs을 삭제합니다.

  6. 새 파일 HexFile.cs을 추가하고 다음 클래스 정의를 추가합니다:

    // HexFile.cs
    public class HexFile
    {
        public string[] Records { get; private set; }
    
        public HexFile(string path)
        {
            this.Records = System.IO.File.ReadAllLines(path);
        }
    }
    

테스트 프로젝트 생성

  1. 솔루션을 마우스 오른쪽 버튼으로 클릭하고 새 프로젝트 MSTest Test Project를 추가합니다.

  2. 프로젝트 이름 설정 TestProject

  3. 프로젝트의 대상 프레임워크를 .NET Framework 4.8로 설정합니다

    Screenshot of NetFramework Test project in Visual Studio.

Fakes 어셈블리 추가

  1. 프로젝트 참조를 HexFileReader에 추가합니다.

    Screenshot of the command Add Project Reference.

  2. Fakes 어셈블리 추가

    • 솔루션 탐색기에서

      • 이전 .NET Framework 프로젝트(비 SDK 스타일)의 경우 단위 테스트 프로젝트의 참조 노드를 확장합니다.

      • .NET Framework, .NET Core 또는 .NET 5+를 대상으로 하는 SDK 스타일 프로젝트의 경우 Dependencies 노드를 확장하여 Assemblies, Projects 또는 Packages에서 위조하려는 어셈블리를 찾습니다.

      • Visual Basic에서 작업하는 경우 솔루션 탐색기 도구 모음에서 모든 파일 표시를 선택하여 참조 노드를 봅니다.

    • System.IO.File.ReadAllLines의 정의가 포함된 어셈블리 System을 선택합니다.

    • 바로 가기 메뉴에서 Fakes 어셈블리 추가를 선택합니다.

    Screnshot of the command Add Fakes Assembly.

일부 유형은 심과 함께 사용할 수 없기 때문에 빌드 시 경고 및 오류가 발생하므로 이를 제외하려면 Fakes\mscorlib.fakes의 내용을 수정해야 합니다.

<Fakes xmlns="http://schemas.microsoft.com/fakes/2011/" Diagnostic="true">
  <Assembly Name="mscorlib" Version="4.0.0.0"/>
  <StubGeneration>
    <Clear/>
  </StubGeneration>
  <ShimGeneration>
    <Clear/>
    <Add FullName="System.IO.File"/>
    <Remove FullName="System.IO.FileStreamAsyncResult"/>
    <Remove FullName="System.IO.FileSystemEnumerableFactory"/>
    <Remove FullName="System.IO.FileInfoResultHandler"/>
    <Remove FullName="System.IO.FileSystemInfoResultHandler"/>
    <Remove FullName="System.IO.FileStream+FileStreamReadWriteTask"/>
    <Remove FullName="System.IO.FileSystemEnumerableIterator"/>
  </ShimGeneration>
</Fakes>

단위 테스트 생성

  1. 기본 파일 UnitTest1.cs을 수정하여 다음 TestMethod를 추가합니다.

    [TestMethod]
    public void TestFileReadAllLine()
    {
        using (ShimsContext.Create())
        {
            // Arrange
            System.IO.Fakes.ShimFile.ReadAllLinesString = (s) => new string[] { "Hello", "World", "Shims" };
    
            // Act
            var target = new HexFile("this_file_doesnt_exist.txt");
    
            Assert.AreEqual(3, target.Records.Length);
        }
    }
    

    다음은 모든 파일을 보여주는 솔루션 탐색기입니다.

    Screenshot of Solution Explorer showing all files.

  2. 테스트 탐색기를 열고 테스트를 실행합니다.

각 shim 컨텍스트를 올바르게 삭제하는 것이 중요합니다. 경험상, using 문 내에서 ShimsContext.Create를 호출하여 등록된 shim이 제대로 지워지도록 합니다. 예를 들어 항상 2000년 1월 1일을 반환하는 대리자로 DateTime.Now 메서드를 대체하는 테스트 메서드에 대해 shim을 등록할 수 있습니다. 테스트 메서드에서 등록된 shim을 지우지 않으면 테스트 실행의 나머지 부분에서 항상 2000년 1월 1일을 DateTime.Now 값으로 반환합니다. 이 결과는 놀라움과 혼동을 줄 수 있습니다.


심 클래스의 명명 규칙

shim 클래스 이름은 원래 형식 이름에 Fakes.Shim 접두사를 추가하여 구성합니다. 매개 변수 이름이 메서드 이름에 추가됩니다. (System.Fakes에는 어셈블리 참조를 추가할 필요가 없습니다.)

    System.IO.File.ReadAllLines(path);
    System.IO.Fakes.ShimFile.ReadAllLinesString = (path) => new string[] { "Hello", "World", "Shims" };

심의 작동 방식 이해하기

심은 테스트 중인 애플리케이션의 코드베이스에 디투어를 도입하여 작동합니다. 원래 메서드에 대한 호출이 있을 때마다 Fakes 시스템이 개입하여 해당 호출을 리디렉션하여 원래 메서드 대신 사용자 지정 쉼 코드가 실행되도록 합니다.

이러한 우회는 런타임에 동적으로 생성 및 제거된다는 점에 유의해야 합니다. 우회는 항상 ShimsContext의 수명 내에 생성되어야 합니다. 심컨텍스트가 폐기되면 그 안에 생성된 모든 활성 심도 제거됩니다. 이를 효율적으로 관리하려면 using 문 안에 우회 경로 생성을 캡슐화할 것을 권장합니다.


다양한 메서드에 대한 shim

심은 다양한 유형의 메서드를 지원합니다.

정적 메서드

정적 메서드를 심링할 때 심을 보유하는 프로퍼티는 심 유형 안에 들어갑니다. 이러한 프로퍼티에는 대상 메서드에 델리게이트를 연결하는 데 사용되는 세터만 있습니다. 예를 들어 정적 메서드 MyMethod가 있는 MyClass이라는 클래스가 있다고 가정해 봅시다:

//code under test
public static class MyClass {
    public static int MyMethod() {
        ...
    }
}

MyMethod에 심을 연결하여 지속적으로 5를 반환하도록 할 수 있습니다:

// unit test code
ShimMyClass.MyMethod = () => 5;

모든 인스턴스에 대한 인스턴스 메서드

정적 메서드와 마찬가지로 인스턴스 메서드도 모든 인스턴스에 대해 심을 지정할 수 있습니다. 이러한 심을 보유하는 프로퍼티는 혼동을 방지하기 위해 AllInstances라는 중첩된 유형에 배치됩니다. 인스턴스 메서드 MyMethod가 있는 클래스 MyClass이 있다고 가정해 봅시다:

// code under test
public class MyClass {
    public int MyMethod() {
        ...
    }
}

MyMethod에 심을 연결하여 인스턴스에 관계없이 일관되게 5를 반환하도록 할 수 있습니다:

// unit test code
ShimMyClass.AllInstances.MyMethod = () => 5;

생성된 ShimMyClass의 타입 구조는 다음과 같이 나타납니다:

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass> {
    public static class AllInstances {
        public static Func<MyClass, int>MyMethod {
            set {
                ...
            }
        }
    }
}

이 시나리오에서 Fakes는 런타임 인스턴스를 델리게이트의 첫 번째 인수로 전달합니다.

인스턴스 메서드(단일 런타임 인스턴스)

인스턴스 메서드는 호출의 수신자에 따라 다른 델리게이트를 사용하여 시밍할 수도 있습니다. 이를 통해 동일한 인스턴스 메서드가 해당 유형의 인스턴스마다 다른 동작을 나타낼 수 있습니다. 이러한 심을 보유하는 프로퍼티는 심 유형 자체의 인스턴스 메서드입니다. 인스턴스화된 각 심 타입은 심 타입의 원시 인스턴스에 연결됩니다.

예를 들어 인스턴스 메서드 MyMethod를 포함하는 MyClass 클래스가 있다고 가정합니다.

// code under test
public class MyClass {
    public int MyMethod() {
        ...
    }
}

에 대해 두 개의 쉼 유형을 만들어 첫 번째는 일관되게 5를 반환하고 두 번째는 일관되게 10을 반환하도록 할 수 있습니다:

// unit test code
var myClass1 = new ShimMyClass()
{
    MyMethod = () => 5
};
var myClass2 = new ShimMyClass { MyMethod = () => 10 };

생성된 ShimMyClass의 타입 구조는 다음과 같이 나타납니다:

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass> {
    public Func<int> MyMethod {
        set {
            ...
        }
    }
    public MyClass Instance {
        get {
            ...
        }
    }
}

shim된 실제 형식 인스턴스는 Instance 속성을 통해 액세스할 수 있습니다.

// unit test code
var shim = new ShimMyClass();
var instance = shim.Instance;

심 타입에는 심 타입에 대한 암시적 변환도 포함되어 있어 심 타입을 직접 사용할 수 있습니다:

// unit test code
var shim = new ShimMyClass();
MyClass instance = shim; // implicit cast retrieves the runtime instance

생성자

생성자도 쉬밍의 예외는 아니며, 생성자도 쉬밍하여 향후 생성될 객체에 쉬밍 유형을 첨부할 수 있습니다. 예를 들어, 모든 생성자는 심 타입 내에서 Constructor이라는 정적 메서드로 표현됩니다. 정수를 받는 생성자가 있는 MyClass 클래스를 생각해 봅시다:

public class MyClass {
    public MyClass(int value) {
        this.Value = value;
    }
    ...
}

생성자에 대한 심 타입은 생성자에게 전달된 값에 관계없이 향후 모든 인스턴스가 값 가져 오기를 호출할 때 -5를 반환하도록 설정할 수 있습니다:

// unit test code
ShimMyClass.ConstructorInt32 = (@this, value) => {
    var shim = new ShimMyClass(@this) {
        ValueGet = () => -5
    };
};

각 심 타입은 두 가지 유형의 생성자를 노출합니다. 기본 생성자는 새 인스턴스가 필요할 때 사용해야 하는 반면, 심 인스턴스를 인수로 받는 생성자는 생성자 심에서만 사용해야 합니다:

// unit test code
public ShimMyClass() { }
public ShimMyClass(MyClass instance) : base(instance) { }

ShimMyClass에 대해 생성된 타입의 구조는 다음과 같이 설명할 수 있습니다.

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass>
{
    public static Action<MyClass, int> ConstructorInt32 {
        set {
            ...
        }
    }

    public ShimMyClass() { }
    public ShimMyClass(MyClass instance) : base(instance) { }
    ...
}

기본 멤버 액세스

기본 멤버의 심 속성은 기본 유형에 대한 심을 생성하고 기본 심 클래스의 생성자에 자식 인스턴스를 입력하면 접근할 수 있습니다.

예를 들어 인스턴스 메서드 MyMethod와 서브타입 MyChild이 있는 클래스 MyBase을 생각해 봅시다:

public abstract class MyBase {
    public int MyMethod() {
        ...
    }
}

public class MyChild : MyBase {
}

MyBase의 심은 새로운 ShimMyBase 심을 시작하여 설정할 수 있습니다:

// unit test code
var child = new ShimMyChild();
new ShimMyBase(child) { MyMethod = () => 5 };

기본 심 생성자에 매개변수로 전달되면 자식 심 유형이 암시적으로 자식 인스턴스로 변환된다는 점에 유의해야 합니다.

ShimMyChildShimMyBase에 대해 생성된 타입의 구조는 다음 코드에 비유할 수 있습니다:

// Fakes generated code
public class ShimMyChild : ShimBase<MyChild> {
    public ShimMyChild() { }
    public ShimMyChild(Child child)
        : base(child) { }
}
public class ShimMyBase : ShimBase<MyBase> {
    public ShimMyBase(Base target) { }
    public Func<int> MyMethod
    { set { ... } }
}

정적 생성자

shim 형식은 형식의 정적 생성자를 shim하는 정적 메서드 StaticConstructor를 노출합니다. 정적 생성자는 한 번만 실행되므로 형식의 멤버에 액세스하기 전에 shim이 구성되는지 확인해야 합니다.

종료자

종료자는 Fakes에서 지원되지 않습니다.

프라이빗 메서드

Fakes 코드 생성기는 서명에 표시되는 형식, 즉 표시되는 매개 변수 형식 및 반환 형식만 있는 전용 메서드에 대해 shim 속성을 만듭니다.

바인딩 인터페이스

shim된 형식이 인터페이스를 구현하는 경우 코드 생성기에서 해당 인터페이스의 모든 멤버를 한 번에 바인딩할 수 있는 메서드를 내보냅니다.

예를 들어 IEnumerable<int>를 구현하는 MyClass 클래스가 있다고 가정합니다.

public class MyClass : IEnumerable<int> {
    public IEnumerator<int> GetEnumerator() {
        ...
    }
    ...
}

Bind 메서드를 호출하여 MyClass에서 IEnumerable<int>의 구현을 shim할 수 있습니다.

// unit test code
var shimMyClass = new ShimMyClass();
shimMyClass.Bind(new List<int> { 1, 2, 3 });

ShimMyClass의 생성된 타입 구조는 다음 코드와 유사합니다:

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass> {
    public ShimMyClass Bind(IEnumerable<int> target) {
        ...
    }
}

기본 동작 변경

생성된 각 심 타입에는 IShimBehavior 인터페이스의 인스턴스가 포함되어 있으며, ShimBase<T>.InstanceBehavior 속성을 통해 액세스할 수 있습니다. 이 동작은 클라이언트가 명시적으로 쉬밍되지 않은 인스턴스 멤버를 호출할 때마다 호출됩니다.

기본적으로 특정 동작이 설정되지 않은 경우 정적 ShimBehaviors.Current 속성에서 반환된 인스턴스가 사용되며, 일반적으로 NotImplementedException 예외가 발생합니다.

심 인스턴스에 대한 InstanceBehavior 속성을 조정하여 언제든지 이 동작을 수정할 수 있습니다. 예를 들어 다음 코드 조각은 아무 작업도 수행하지 않거나 반환 유형의 기본값(예: default(T))을 반환하도록 동작을 변경합니다:

// unit test code
var shim = new ShimMyClass();
//return default(T) or do nothing
shim.InstanceBehavior = ShimBehaviors.DefaultValue;

또한 정적 ShimBehaviors.Current 속성을 설정하여 InstanceBehavior 속성이 명시적으로 정의되지 않은 모든 심 인스턴스에 대한 동작을 전역적으로 변경할 수도 있습니다:

// unit test code
// change default shim for all shim instances where the behavior has not been set
ShimBehaviors.Current = ShimBehaviors.DefaultValue;

외부 종속성과의 상호작용 식별

코드가 외부 시스템 또는 종속성(environment이라고 함)과 상호 작용하는 경우를 식별하기 위해 쉼을 활용하여 유형의 모든 멤버에 특정 동작을 할당할 수 있습니다. 여기에는 정적 메서드가 포함됩니다. 심 타입의 정적 Behavior 프로퍼티에 ShimBehaviors.NotImplemented 동작을 설정하면 명시적으로 심하지 않은 해당 타입의 멤버에 액세스하면 NotImplementedException이 발생합니다. 이는 테스트 중에 코드가 외부 시스템이나 종속성에 액세스하려고 시도하고 있음을 나타내는 유용한 신호로 사용될 수 있습니다.

다음은 단위 테스트 코드에서 이를 설정하는 방법의 예시입니다:

// unit test code
// Assign the NotImplementedException behavior to ShimMyClass
ShimMyClass.Behavior = ShimBehaviors.NotImplemented;

편의를 위해 동일한 효과를 얻을 수 있는 단축 방법도 제공됩니다:

// Shorthand to assign the NotImplementedException behavior to ShimMyClass
ShimMyClass.BehaveAsNotImplemented();

심 메서드 내에서 원본 메서드 호출하기

심 메서드를 실행하는 동안 원래 메서드를 실행해야 하는 경우가 있을 수 있습니다. 예를 들어 메서드에 전달된 파일 이름의 유효성을 검사한 후 파일 시스템에 텍스트를 쓰고 싶을 수 있습니다.

이 상황을 처리하는 한 가지 방법은 다음 코드에서와 같이 델리게이트와 ShimsContext.ExecuteWithoutShims()을 사용하여 원래 메서드에 대한 호출을 캡슐화하는 것입니다:

// unit test code
ShimFile.WriteAllTextStringString = (fileName, content) => {
  ShimsContext.ExecuteWithoutShims(() => {

      Console.WriteLine("enter");
      File.WriteAllText(fileName, content);
      Console.WriteLine("leave");
  });
};

또는 심을 무효화하고 원래 메서드를 호출한 다음 심을 복원할 수 있습니다.

// unit test code
ShimsDelegates.Action<string, string> shim = null;
shim = (fileName, content) => {
  try {
    Console.WriteLine("enter");
    // remove shim in order to call original method
    ShimFile.WriteAllTextStringString = null;
    File.WriteAllText(fileName, content);
  }
  finally
  {
    // restore shim
    ShimFile.WriteAllTextStringString = shim;
    Console.WriteLine("leave");
  }
};
// initialize the shim
ShimFile.WriteAllTextStringString = shim;

심 타입으로 동시성 처리하기

심 타입은 앱도메인 내의 모든 스레드에서 작동하며 스레드 친화성을 갖지 않습니다. 이 속성은 동시성을 지원하는 테스트 러너를 활용하려는 경우 염두에 두어야 할 중요한 속성입니다. Fakes 런타임에서는 이 제한이 적용되지 않지만, 심 유형과 관련된 테스트는 동시에 실행할 수 없다는 점에 유의할 필요가 있습니다.

System.Environment 쉬밍

System.Environment 클래스를 쉬밍하려면 mscorlib.fakes 파일을 약간 수정해야 합니다. Assembly 요소 다음에 다음 내용을 추가합니다:

<ShimGeneration>
    <Add FullName="System.Environment"/>
</ShimGeneration>

이렇게 변경하고 솔루션을 다시 빌드하면 이제 System.Environment 클래스의 메서드와 프로퍼티를 심링할 수 있습니다. 다음은 GetCommandLineArgsGet 메서드에 동작을 할당하는 방법의 예시입니다:

System.Fakes.ShimEnvironment.GetCommandLineArgsGet = ...

이러한 수정을 통해 코드가 시스템 환경 변수와 상호 작용하는 방식을 제어하고 테스트할 수 있는 가능성을 열었으며, 이는 포괄적인 단위 테스트에 필수적인 도구입니다.