Att enhetstesta WCF-tjänster

Jag möts gång på gång av tappra försök till att “enhetstesta” WCF tjänster där det i själva verket har resulterat i mer eller mindre komplexa integrationstester. Anledningarna till detta har oftast sin grund i att det man byggt nyttjar WCF:s OperationContext eller ServiceSecurityContext för att hämta information från inkommande meddelande eller anropande användare.

OperationContext och ServiceSecurityContext instansieras av WCF hosten och är således inte tillgängliga utanför WCF:s exekveringskontext. Lösningen på detta anses därför i många fall  vara att helt enkelt bygga “enhetstester” som hostar aktuell WCF-tjänst för att på så vis få tillgång till OperationContext och ServiceSecurityContext instanserna. Jag säger inte att detta är fel. Däremot vill jag absolut kalla denna typ av tester för Integrationstester. Man testar helt enkelt sin tjänst tillsammans med övriga komponenter av vilka man inte har full kontroll över.

Låt mig visa ett exempel. Nedan operation returnerar ett klasiskt “Hello” med användarnamnet på anropande användare.

 [OperationBehavior(Impersonation = ImpersonationOption.Required)]
public string Hello() {

    var user = ServiceSecurityContext.Current.WindowsIdentity.Name;
    return "Hello " + user;

}

Ur ett enhetstestperspektiv finns det två problem med ovan “enkla” operation.

  1. ServiceSecurityContext.Current kommer vara null om operationen inte exekverar inom en WCF host.
  2. Om man istället sätter upp ett integrationstest för att få fatt i en instans av ServiceSecurityContext så kommer WindowsIdentity.Name returnera användarnamnet på det konto som exekverar testet. Dvs synnerligen svårt att skriva ett test som alltid är sant.

Hur skall jag då göra för att verkligen kunna enhetstesta ovan operation? Som i många andra sammanhang med enhetstestning så får jag helt enkelt addera en abstraktion på, i detta fall, ServiceSecurityContext för att kunna ersätta implementationen med en Mock eller Stub. Det som kan röra till det hela i WCF':s fall är att ServiceSecurityContext inte implementerar något Interface eller basklass som jag kan utgå ifrån. Jag börjar således med att skapa ett Interface som ser likadant ut som ServiceSecurityContext.

 namespace Chrislof.ServiceModel.Abstractions {
    
    public interface IServiceSecurityContext {

        AuthorizationContext AuthorizationContext { get; }

        ReadOnlyCollection<IAuthorizationPolicy> AuthorizationPolicies { get; }

        bool IsAnonymous { get; }

        IIdentity PrimaryIdentity { get; }

        IWindowsIdentity WindowsIdentity { get; }

    }
}

Detta Interface implementerar jag sedan i en sk Wrapper. Denna wrapper-implementation tar en referens till aktuellt ServiceSecurityContext och vidarebefordrar endast anropen till den “riktiga” ServiceSecurityContext instansen.

 namespace Chrislof.ServiceModel.Abstractions {
    /// <summary>
    /// Decorates the <see cref="ServiceSecurityContext"/> for testability
    /// </summary>
    public class ServiceSecurityContextWrapper : IServiceSecurityContext
    {
        private readonly ServiceSecurityContext _context;

        public ServiceSecurityContextWrapper(ServiceSecurityContext context) {

            context.ArgumentNotNull("context");
            _context = context;
        }

        public AuthorizationContext AuthorizationContext {
            get { return _context.AuthorizationContext; }
        }

        public ReadOnlyCollection<IAuthorizationPolicy> AuthorizationPolicies {
            get { return _context.AuthorizationPolicies; }
        }

        public bool IsAnonymous {
            get { return _context.IsAnonymous; }
        }

        public IIdentity PrimaryIdentity {
            get { return _context.PrimaryIdentity; }
        }

        public IWindowsIdentity WindowsIdentity {
            get { return new WindowsIdentityWrapper(_context.WindowsIdentity); }
        }
    }
}

För att nyttja ovan ServiceSecurityContextWrapper i min tjänsteimplementation adderar jag en constructor och ett privat fält som håller referensen till wrappern.

 public Service(IServiceSecurityContext securityContext) {
    _securityContext = securityContext;
}

Hello-operationen justeras till följande.

 [OperationBehavior(Impersonation = ImpersonationOption.Required)]
public string Hello() {

    var user = _securityContext.WindowsIdentity.Name;
    return "Hello " + user;

}

Detta gör att jag nu kan tilldela min tjänst med ett ServiceSecurityContext som jag har full kontroll över inom ramen av mitt enhetstest. Jag använder i nedan exempel ramverket Moq för att skapa en ServiceSecurityContext-stub som returnerar mitt användarnamn.

 [TestMethod]
public void Hello_WhenCalled_ReturnsHelloWithCurrentUser() {

    const string user = "Chrislof";

    var securityContext = new Mock<IServiceSecurityContext>();
    securityContext.SetupGet(x => x.WindowsIdentity.Name).Returns(user);
    
    var service = new Service(securityContext.Object);

    var response = service.Hello();

    Assert.AreEqual("Hello Chrislof",response);

}

Ovan konstruktor som kräver en parameter är inget som WCF diggar alls som standard. (Se tidigare artikel ang detta) Jag behöver dessutom få WCF att ge mig en instans av min ServiceSecurityContextWrapper. Det absolut enklaste sättet att lösa detta är att infoga en defaultkonstruktor utan parametrar och i den instansiera ServiceSecurityContextWrapper.

 public Service() : 
    this(new ServiceSecurityContextWrapper(ServiceSecurityContext.Current)) {}

Ovan exempel finns att ladda ned här nedan. Projektet inkluderar även ett mer avancerat scenario med en ServiceAuthorizationManager. Använd gärna mina Abstraktions- klasser men kom ihåg – de kommer as-is utan någon som helst garanti eller support.

Happy testing!