Automatyzacja projektu z MSBuild-em - 4. TDD

Jul 16, 2008 - 4 minute read -

W tym odcinku zajmiemy się testowaniem.

Rozdzielna budowa

Na początek warto stworzyć dwa pomocnicze targety: “BuildApp” oraz “BuildTest”. Pierwszy z nich będzie oczywiście budował projekty składające się na aplikację, a drugi testy. Do tego potrzebujemy listy projektów z rozbiciem na dwa typy. To zrobi dla nas target “GetProjectsFromSolution”, który umieszczamy w Default.proj

<Target Name="GetProjectsFromSolution" >
 
    <!-- Get all the projects associated with the solution -->
    <GetSolutionProjects Solution="$(SolutionPath)">
        <Output TaskParameter="Output" ItemName="SolutionProjects" />
    </GetSolutionProjects>
 
    <!-- Filter out solution folders and non .csproj items -->
    <RegexMatch Input="@(SolutionProjects)" Expression=".[\.]csproj$">
        <Output TaskParameter="Output" ItemName="Projects"/>
    </RegexMatch>
 
    <!-- Resolve test projects -->
    <RegexMatch Input="@(Projects)" Expression="$(TestDetectionExpression)[\.]csproj$">
        <Output TaskParameter="Output" ItemName="TestProjects"/>
    </RegexMatch>
 
    <!-- Resolve the libraries code projects -->
    <ItemGroup>
        <AppProjects Include="@(Projects)" Exclude="@(TestProjects)"/>
    </ItemGroup>
 
    <Message Text="$(NEW_LINE)Resolved the following solution projects:" Importance="high" />
    <Message Text="AppProjects:$(NEW_LINE)$(TAB)@(AppProjects->'%(RelativeDir)%(FileName)%(Extension)', '$(NEW_LINE)$(TAB)')" Importance="high"/>
    <Message Text="TestProjects:$(NEW_LINE)$(TAB)@(TestProjects->'%(RelativeDir)%(FileName)%(Extension)', '$(NEW_LINE)$(TAB)')" Importance="high"/>
</Target>

Pomocnicze property, które później też wykorzystamy znajduje się w Settings.proj

<TestDetectionExpression>.[\.](Test[s]{0,1})</TestDetectionExpression>

Jak łatwo się domyśleć jego wykonanie będzie skutkować takim wynikiem

C:\...\trunk> msbuild Default.proj /t:GetProjectsFromSolution
...
Resolved the following solution projects:
AppProjects:
      C:\...\trunk\src\app\MyProject\MyProject.csproj
TestProjects:
      C:\...\trunk\src\test\MyProject.Tests\MyProject.Tests.csproj
...

Parę słów wyjaśnienia. Zwróćcie uwagę, że lista projektów jest pobierana z solution, z property “SolutionPath”. Jeżeli nasza aplikacja składa sie z wielu projektów, może się okazać, że warto zrobić dodatkowe pliki .sln grupujące projekty w zależności np. od warstwy - MySolution.Services.sln. Teraz, aby zbudować kod z tej okrojonej grupy wystarczy …

C:\...\trunk> msbuild Default.proj /t:BuildApp /p:SolutionPath=.\MySolution.Services.sln

Jak widać do rozdzielenia różnych rodzajów projektów wykorzystujemy expression regexp-a. Należy również zwrócić uwagę na “ItemGroup”. Od wersji MSBuild 3.5 można używać “ItemGroup” jak i “PropertyGroup” wewnątrz znaczników “Target”. W poprzedniej wersji  należało wykorzystywać “CreateItem” i “CreateProperty”. Dodatkowo możemy teraz usuwać wartości z ItemGroup np.

<ItemGroup>
    <!—Remove *.licx from the EmbeddedResource list - ->
    <EmbeddedResource Remove="*.licx" />
</ItemGroup>

Aby zwiększyć czytelność dodałem pomocnicze properties takie jak $(NEW_LINE) i $(TAB). Są one zadeklarowane w pliku Settings.proj w następujący sposób…

<!-- ASCII Constants -->
<PropertyGroup>
    <NEW_LINE>%0D%0A</NEW_LINE>
    <TAB>%09</TAB>
    <DOUBLE_QUOTES>%22</DOUBLE_QUOTES>
    <SPACE>%20</SPACE>
</PropertyGroup>

Teraz pozostaje nam zbudować brakujące zadania “BuildApp” i “BuildTest” oraz zrefaktorować target “BuildAll”, który powstał w poprzednim odcinku.

<Target Name="BuildAll" DependsOnTargets="BuildApp;BuildTest"/>
 
<Target Name="BuildApp" DependsOnTargets="GetProjectsFromSolution" >
    
    <MSBuild Projects="@(AppProjects)"
             Targets="Build">
        <Output TaskParameter="TargetOutputs" ItemName="AppAssemblies"/>
    </MSBuild>
    
    <!-- Add all assemblies to all build assemblies -->
    <ItemGroup>
        <BuildAssemblies Include="@(AppAssemblies)"/>
    </ItemGroup>
</Target>
 
<Target Name="BuildTest" DependsOnTargets="GetProjectsFromSolution" >
    <MSBuild Projects="@(TestProjects)"
             Targets="Build">
        <Output TaskParameter="TargetOutputs" ItemName="TestAssemblies"/>
    </MSBuild>
 
    <!-- Add all assemblies to all build assemblies -->
    <ItemGroup>
        <BuildAssemblies Include="@(TestAssemblies)"/>
    </ItemGroup>
</Target>

Wynikiem zadań są properties “AppAssemblies”, “TestAssemblies” oraz “BuildAssemblies”, które zawierają odpowiednio pliki wynikowe aplikacji, testów oraz wszystkie pliki, które powstały w trakcie tego przebiegu budowy.

Uruchomienie testów

Do testów wykorzystałem NUnit v2.5 alpha. Znajduje się on w tools\nunit. Napisałem prostą klasę testująca, która zwraca jeden poprawny i jeden niepoprawny rezultat. Naszym celem jest teraz umożliwienie uruchomienia testu dla pojedynczego projektu jak i zbiorowo czyli …

C:\...\trunk> msbuild src\test\MyProject.Tests\MyProject.Tests.csproj /t:Test
C:\...\trunk> msbuild Default.proj /t:TestAll

Zaczynamy od definicji ścieżki do NUnit w pliku Settings.proj

<NUnitPath>$(ToolsPath)\nunit</NUnitPath>

Następnie tworzymy target “Test” w Common.Targets

<Target Name="Test" DependsOnTargets="Build">
    <ItemGroup>
        <Assemblies Include="$(TargetDir)\$(TargetName).dll" />
    </ItemGroup>
    
    <RegexMatch Input="@(Assemblies)" Expression="$(TestDetectionExpression)[\.]dll$">
        <Output TaskParameter="Output" ItemName="TestAssemblies"/>
    </RegexMatch>
 
    <NUnit Condition=" '@(TestAssemblies)' != '' "
        ToolPath="$(NUnitPath)"
        Assemblies="@(TestAssemblies)" />
</Target>

Tutaj zastosowałem małą sztuczkę w celu wyodrębnienia testów należących do faktycznie kompilowanego projektu. Może zdarzyć się sytuacja, że jeden test zależy od drugiego testu i oba assemblies o końcówce *.Test.dll razem znajda się w $(TargetDir), a nam zależy aby testować tylko jedno, aktualnie zbudowane assembly. Jeżeli nie ma plików testowych, wówczas NUnit nie jest uruchamiany.

“TestAll” w Default.proj możemy zaprojektować na dwa sposoby

<Target Name="TestAll" DependsOnTargets="GetProjectsFromSolution">
    <MSBuild Projects="@(TestProjects)" Targets="Test"/>
</Target>

Czyli wywołanie zadania “Test” dla każdego projektu testowego, co skutkuje również jego budową lub …

<Target Name="TestAll" DependsOnTargets="BuildTest">
    <NUnit Condition=" '@(TestAssemblies)' != '' "
        ToolPath="$(NUnitPath)"
        Assemblies="@(TestAssemblies)" />
</Target>

Pierwsze rozwiązanie wydaje się być bardziej eleganckie, zwłaszcza że drugie rozwiązanie wygląda podobnie jak w Commons.Target, wiec jak coś się zmieni w sposobie uruchamiania testu np. generowanie raportu, wówczas mamy wyraźne złamanie DRY principle i musimy poprawić kod w dwóch miejscach. Ale … Jeżeli mamy więcej projektów testowych to przy pierwszym rozwiązaniu wykonanie będzie następujące:

  1. Build ProjectOne
  2. Test ProjectOne
  3. Build ProjectTwo
  4. Test ProjectTwo

W drugim rozwiązaniu:

  1. Build ProjectOne
  2. Build ProjectTwo
  3. Test ProjectOne
  4. Test ProjectTwo

Osobiście wole kiedy najpierw zbudują mi się wszystkie testy i jak to przebiegnie pomyślnie wówczas je przetestuje. Jednak żeby nikt nam nie zarzucał “Don’t Repeat Yourself” zrobimy mały refaktoring i dodamy do pliku “Settings.proj”…

<Target Name="InternalNUnit">
    <NUnit Condition=" '@(TestAssemblies)' != '' "
        ToolPath="$(NUnitPath)"
        Assemblies="@(TestAssemblies)" />
</Target>

Refaktoring Default.proj

<Target Name="TestAll" DependsOnTargets="BuildTest">
    <CallTarget Targets="InternalNUnit" />
</Target>

Refaktoring Common.Targets

<Target Name="Test" DependsOnTargets="Build;GetTestAssemblies" >
    <CallTarget Targets="InternalNUnit" />
</Target>
 
<Target Name="GetTestAssemblies" DependsOnTargets="Build">
    <ItemGroup>
        <Assemblies Include="$(TargetDir)$(TargetName)*.dll" />
    </ItemGroup>
 
    <RegexMatch Input="@(Assemblies)" Expression="$(TestDetectionExpression)[\.]dll$">
        <Output TaskParameter="Output" ItemName="TestAssemblies"/>
    </RegexMatch>
</Target>

Uwaga: Target “Test” umyślnie rozbiłem na dwa targety. MSBuild ma takie ograniczenie, że jeżeli manipulujemy jakimś Item lub Property to zmiana ta będzie widoczna dla innych targetów dopiero po zakończeniu aktualnego. Zatem manipulacja Property a potem wywołanie CallTarget nie powiodłaby się. Na to trzeba uważać.

Kod do dzisiejszego odcinka znajdziecie tutaj -> Part004

W następnym odcinku zobaczymy jak zrobić Assembly.cs, którego nikt nie będzie widział w projekcie, czyli dynamiczne dokładanie plików.