IoC/DI, varför? (del 2 av 3)

Den allra kortaste förklaringen till varför DI och IoC är bra kan sammanfattas i dom två sista bokstäverna i begreppet S.O.L.I.D och den välkända akronymen TDD.

S.O.L.I.D

Vill man läsa om SOLID så är Google en bra start eller varför inte Wikipedia, SOLID. En lysande föreläsning om SOLID finns här SOLID – The Five Commandments of Good Software.

SOLID kort sammanfattat är fem principer att följa för att få hjälp med att hålla ett systems design ren och enkel att underhålla, felsöka och felavhjälpa. Håller man sig till principerna så kommer man få ett system med renare beroenden, tydliga gränser mellan olika delars ansvar och livet kommer bli smidigare den dagen man bestämmer sig för att bygga om en eller flera delar av systemet. Det mesta bygger på att man inte ska få kod som liknar spagetti med beroenden åt alla håll, uppåt, neråt, fram och tillbaka.

I = Interface segregation (principle) aka ISP

I förra posten när vi fick till DI i exemplet så såg vi till att TheDependentService tog en instans av ett interface som inparameter till konstruktorn, inte själva implementationen av interfacet. Detta gör att TheDependentService inte har kännedom om just implementationen utan bara om just dess gränssnitt. Man kan alltså byta implementation så länge den nya sådana implementerar interfacet. Vilket eller vilka interface som instansen implementerar är helt oviktigt för TheDependentService eftersom den bara är intresserad av just dom metoderna som interfacet deklarerar. Det här innebär alltså att man ytterligare minskar beroendet mellan delarna i systemet.

ISP beskriver just det sistnämda, att stora interface delas upp i mindre dito för att minska ytan av beroenden mellan olika delar. Implementationen kan vara oförändrad, en klass kan implementera flera olika interface, men interfacen bör vara små och specifika för den uppgiften som implementationen av det lilla interfacet ska lösa.

ISP => små tajta interface => minskade ytor för beroenden mellan klasser

D => Dependency inversion (principle)

I förra posten definierade vi löst vad IoC betyder och hur det ser ut i kod. Dependency inversion principle innebär just det vi då kom fram till, att en klass inte ska ha beroenden till den konkreta implementationen av något utan bara till dess abstraktion, dvs gränssnittet som implementationen exponerar. Det låter lite flummigt om man läser om den här principen men tänk dig att du sitter i en bil och trycker på tutan. När man gör det så förväntar man sig att bilen ska tuta. Vad som gör att bilen tutar på ett specifikt sätt behöver jag inte veta, så länge jag trycker på tutan så ska den låta helt enkelt. Det kanske sitter en stor kyrkklocka under motorhuven som börjar ringa, kanske sitter det en kanariefågel där som börjar kvittra eller så sitter det ett signalhorn där. Den som har byggt bilen har bestämt gränssnittet och gjort implementationen, jag som vill tuta behöver bara veta att det finns en tuta att trycka på.

Blev det tydligare? Inte... Ok, läs här då Dependency inversion principle.

Allt handlar egentligen om att man bör minska beroenden mellan olika delar, ett system ska bestå av löst kopplade komponenter om man vill uttrycka det populärt.

Test-driven development aka TDD

Vill man testdriva fram sitt system, med enhetstestning på klassnivå som minsta enhet att testa så gäller det att man kan isolera sin klass från externa beroenden. För att få till isoleringen så gäller det att man kan påverka klassens beroenden så att dessa beter sig på ett förutsägbart sätt.

Vi tittar återigen på exemplet från tidigare:

public class TheDependentService  
{
    private readonly IStorageService _storageService;

    private object _theData;

    public TheDependentService(IStorageService storageService)
    {
        _storageService = storageService;
    }

    public void Save()
    {
        try
        {
            _storageService.PersistIt(_theData);
        }
        catch (Exception e)
        {
            throw new IOException("Save failed", e);
        }
    }
}

För att göra exemplet lite mer "levande" så har ett try-catch-block lagts in i Save-metoden. Oberoende av fel från _storageService.PersistIt så kastas ett nytt fel med meddelandet "Save failed" och det kastade felet skickas med som ett inner exception.

Notera att koden är skriven INNAN testfallet, vilket såklart inte är speciellt mycket TDD. Testfallet kommer hur som helst alldeles här nedan.

Testfallet som verifierar att felet kastas med korrekt meddelande och rätt inner exception borde då se ut ungefär så här (xUnit + NSubstitute + FluentAssertions används här):

public class Tests  
{
    [Fact]
    public void Save_Should_ThrowExceptionOnError()
    {
        var storageService = Substitute.For<IStorageService>();
        storageService
            .When(x => x.PersistIt(Arg.Any<object>()))
            .Throw<SqlTypeException>();

        var sut = new TheDependentService(storageService);

        Action a = () => sut.Save();

        a.Should().Throw<IOException>()
            .WithMessage("Save failed")
            .WithInnerException<SqlTypeException>();
    }
}

Här kan man se hur man med hjälp av Substitute.For<...> tar kontroll över beteendet hos IStorageService-instansen som sedan injiceras i TheDependentService, variabeln som heter sut, software under test. IStorageService-instansen kommer att kasta ett fel, SqlTypeException, när metoden PersistIt anropas på denna, helt oberoende av vilken inparametern är. Detta gör att vi med säkerhet vet att Save-metoden på TheDependentService ska kasta ett nytt fel, ett IOException, med "Save failed" i meddelandet och med ett SqlTypeException som inner exception, vilket kontrolleras sist i testfallet, genom a.Should().Throw<...>().WithMessage(...)..WithInnerException<...>().

Om man inte hade haft stöd för att injicera ett beroende med ett känt beteende så hade testfallet varit betydligt besvärligare att bygga. Med hjälp av ramverken NSubstitue och FluentAssertions kan man få testkod som är läsbar och faktiskt kan förstås även av icke-programmerare, kanske inte just exemplet ovan men andra lite enklare tester utan exceptions mm.

Hade detta varit möjligt utan DI+IoC? Nja, jo visst hade det varit möjligt men inte alls lika enkelt. DI gör att vi kan injicera beroenden med kända beteenden och IoC gör att vi låter klienten, i det här fallet själva testfallet, bestämma vilken implementation som ska injiceras. DI och IoC möjliggör mocking, dvs att man kan lura en klass att ett objekt är ett riktigt objekt men beteendet är förändrat och kontrollerat. Mock betyder just falsk, fingerad, spelad och mocking betyder gäcka, lura, driva med.

Vill man läsa om TDD med hjäp av xUnit, NSubstitute och FluentAssertions så finns det mängder av artiklar och step-by-steps att hitta här ute på nätet.

I nästa del i serien diskuteras om man behöver använda något ramverk för DI och IoC. Den är just nu under konstruktion och kommer alldeles snart.

Artiklar i serien