Creating a build script for an ASP.Net MVC web application with Fake

17/4/2017

Creating a build script, always a hassle to do and one of the things that gets the lowest priority in the backlog. Certainly on small projects. On one of my previous work places MSBuild was used to automate the builds. I had such a bad experience with it that I just plain ignored it for several years. It was hard to use and maintain. I heard of Fake, a build automation system written in F# which gets its inspiration from rake and make, and gave it a try!

I'm not going to rewrite the getting started guide that is available on the Fake website, it's an excellent guide to start with and gets you up to speed quickly. The most important things I wanted to accomplish were:

  • Build the project in release (pretty obvious)
  • Run unit tests
  • Run integration tests
  • Create a deploy with the necessary files

The getting started guide handles most of these steps, the result of the guide is the script below.

// include Fake lib
#r "packages/FAKE/tools/FakeLib.dll"
open Fake 

RestorePackages() 

// properties 
let buildDir = "./build/"
let testDir  = "./test/"

// Targets
Target "Clean" (fun _ ->
    CleanDirs [buildDir; testDir]
)

Target "BuildApp" (fun _ ->
    !! "src/app/**/*.csproj"
        |> MSBuildRelease buildDir "Build"
        |> Log "AppBuild-Output: "
 )

 Target "BuildTest" (fun _ ->
    !! "src/test/**/*.csproj"
        |> MSBuildDebug testDir "Build"
        |> Log "TestBuild-Output: "
)

Target "Test" (fun _ ->
    !! (testDir + "/NUnit.Test.*.dll")
        |> NUnit (fun p ->
                 {p with
                       DisableShadowCopy = true;
                     OutputFile = testDir + "TestResults.xml" })
 )

Target "Default" (fun _ ->
    trace "Hello World from FAKE"
)

// Dependencies
"Clean"
==> "BuildApp"
==> "BuildTest"
==> "Test"
==> "Default"

// start build
RunTargetOrDefault "Default"

It does most of the things that I wanted to accomplish but I needed some additional steps. I slightly changed the folder structure for my project: one build folder which has some subfolders where the output of the build script will be placed. This is the internal folder structure of the build folder:

  • _deploy > a folder with the files that can be deployed
  • build > this will hold the result of the BuildApp step
  • test > this will hold the result of the BuildTest step
  • tools > the tools that the build script uses will be placed here

First change I'll make is adding steps for unit and integration tests. For the sake of completion, most of my Visual Studio solutions have 3 projects. If the application has the name WebApplication then there is a project named WebApplication, one named WebApplication.UnitTests and one WebApplication.IntegrationTests.

After making the necessary changes to the paths in the build script to use the new folder structure, the build script failed on the Test step. The first problem was pretty easy to fix, the nunit-console.exe runner was missing. But even after putting it in the correct place, the step kept failing. To be able to run tests from a project that uses NUnit 3 you need to use a different Fake method. This method resides in the Fake.Testing module which needs to be opened at the beginning of the build script.

#r @"packages\FAKE\tools\FakeLib.dll"
open Fake
open Fake.Testing

Then the Test step is changed to use the NUnit3 method.

Target "Test" (fun _ ->
    !! (testDir + "/*.UnitTests.dll")
        |> NUnit3 (fun p ->
            {p with
                ToolPath = "build/tools/Nunit/nunit-console3.exe"
            })
)

Now the unit tests are run every time the build script runs. Next up are the integration tests. This should be easy, we'll just use the Test step to run all tests of the assemblies that end in *.*Tests.dll.

Target "Test" (fun _ ->
    !! (testDir + "/*.*Tests.dll")
        |> NUnit3 (fun p ->
            {p with
                ToolPath = "build/tools/Nunit/nunit-console3.exe"
            })
)

Unfortunately after this change the unit tests run fine, but the integration tests fail.

Invalid : path\to\integration\WebApplication.IntegrationTests.dll
Unable to load one or more of the requested types. Retrieve the LoaderExceptions property for more information.

This is pretty weird, all necessary assemblies are present in the test build directory. If you ever get this error, you can check which types are causing this error. Just run the following command in a Windows Powershell and it will output the types that cause the problem.

[Reflection.Assembly]::LoadFile('<path to your assembly>') | % {$_.GetTypes()}
$Error[0].Exception.InnerException.LoaderExceptions

This command pointed at StructureMap being the assembly that could not be loaded, but it looked fine. After some trial-and-error it seemed like the integration tests ran fine when only that project was build. Whenever both the unit and integration tests were built the Test step failed when trying to run the integration tests. After splitting up the two projects in different steps both the unit tests and the integration tests ran fine.

Target "BuildUnitTest" (fun _ ->
    !! "Code/**/*.UnitTests.csproj"
        |> MSBuildDebug testDir "Build"
        |> Log "TestBuild-Output: "
)

Target "BuildIntegrationTest" (fun _ ->
    !! "Code/**/*.IntegrationTests.csproj"
        |> MSBuildDebug testDir "Build"
        |> Log "TestBuild-Output: "
)

Target "UnitTest" (fun _ ->
    !! (testDir + "/*.UnitTests.dll")
        |> NUnit3 (fun p ->
            {p with
                ToolPath = "build/tools/Nunit/nunit-console3.exe"
            })
)

Target "IntegrationTest" (fun _ ->
    !! (testDir + "/*.IntegrationTests.dll")
        |> NUnit3 (fun p ->
            {p with
                ToolPath = "build/tools/Nunit/nunit-console3.exe"
            })
)

Last step that needed to be done was copying the necessary files to the _deploy folder. At first I wanted to use a specific publish profile to publish the web application to the _deploy folder. I'm not a huge fan of the publish feature in Visual Studio but it works for simple cases. Big drawback of that method was that each project needed a publish profile or the build script could not run. So that route was abandoned and instead I used the _PublishedWebsites folder that is created when building a web application in release. The _PublishedWebsites folder is copied with XCopy to the _deploy folder, then the unwanted files are removed with the DeleteFile and DeleteDir methods.

Target "Deploy"(fun _ ->
    let webBuildPath = buildDir + "/_PublishedWebsites/" + projectName

    XCopy webBuildPath deployDir

    DeleteFile(deployDir + "Web.config")
    DeleteDir(deployDir + "Source")
)

That was the last step I wanted to accomplish. You can find the finished script at the end of this article. In the future more steps will probably be added to the script, the next thing I'm thinking about is running Grunt before creating the deploy. But for the moment I'm pretty happy with the result, it was a fun experience getting to play around with F# and I got something useful out of it!

Update 28/04/2017

I've added a Zip step to the build script, it zips the deploy folder to 1 zip which makes it easier to copy or upload.

Target "Zip" (fun _ ->

    let zipFile = projectName + ".zip"

    !! (deployDir + "/**/*.*")
        -- "./*.zip"
        |> Zip deployDir ("./build/" + zipFile)

    CleanDir deployDir
    CopyFile (deployDir + zipFile) ("./build/" + zipFile)
    DeleteFile ("./build/" + zipFile)
)

You'll notice that the zip file is first created in the build folder and is then copied over to the deploy folder. If the Zip method is run putting the result zip file in the same folder as the folder that is being zipped the following error occurs:

System.IO.IOException: The process cannot access the file '<path to zip file> because it is being used by another process.

This is a known bug, the Zip method tries to add the zip it creates to the zip file and obviously fails. To work around this issue the zip is first created in another folder and then copied over to the deploy folder. Full updated script below!

// include Fake lib
#r @"packages\FAKE\tools\FakeLib.dll"
open Fake
open Fake.Testing

// - PROJECT SPECIFIC SETTINGS --------------------------
// ------------------------------------------------------
let projectName = "DaikinRoomByRoom"

// - BUILD SCRIPT ---------------------------------------
// ------------------------------------------------------

// Properties
let buildDir = "./build/build/"
let testDir = "./build/test/"
let deployDir = "./build/_deploy/"

// Targets
Target "Clean"(fun _ ->
    CleanDir buildDir
    CleanDir testDir
    CleanDir deployDir
)

Target "BuildApp"(fun _ ->

    !! "Code/**/*.csproj"
    |> MSBuildRelease buildDir "Build"        
    |> Log "AppBuild-Output: "
)

Target "BuildUnitTest" (fun _ ->
    !! "Code/**/*.UnitTests.csproj"
    |> MSBuildDebug testDir "Build"
    |> Log "TestBuild-Output: "
)

Target "BuildIntegrationTest" (fun _ ->
    !! "Code/**/*.IntegrationTests.csproj"
    |> MSBuildDebug testDir "Build"
    |> Log "TestBuild-Output: "
)

Target "UnitTest" (fun _ ->
    !! (testDir + "/*.UnitTests.dll")
    |> NUnit3 (fun p ->
        {p with
        ToolPath = "build/tools/Nunit/nunit-console3.exe"
        })
)

Target "IntegrationTest" (fun _ ->
    !! (testDir + "/*.IntegrationTests.dll")
    |> NUnit3 (fun p ->
        {p with
        ToolPath = "build/tools/Nunit/nunit-console3.exe"
        })
)

Target "Deploy"(fun _ ->
    let webBuildPath = buildDir + "/_PublishedWebsites/" + projectName

    XCopy webBuildPath deployDir
// delete the assets folder because some assets may not be included in the csproj file
    DeleteDir (deployDir + "Assets")
// recopy the assets folder
    CopyDir (deployDir + "Assets") ("./Code/" + projectName + "/Assets") allFiles

    DeleteFile (deployDir + "Web.config")
    DeleteFile (deployDir + "Web.Debug.config")
    DeleteFile (deployDir + "Web.Release.config")

    DeleteDir (deployDir + "Source")
)

Target "Zip" (fun _ ->

    let zipFile = projectName + ".zip"

    !! (deployDir + "/**/*.*")
    -- "./*.zip"
    |> Zip deployDir (deployDir + zipFile)

     // CljeanDir deployDir
     // CopyFile (deployDir + zipFile) ("./build/" + zipFile)
     // DeleteFile ("./build/" + zipFile)
)

Target "Default" (fun _ ->
    trace "Build script finished"
)

// Dependencies
"Clean"
    ==> "BuildApp"
    ==> "BuildUnitTest"
    ==> "BuildIntegrationTest"
    ==> "UnitTest"
    ==> "IntegrationTest"
    ==> "Deploy"
    ==> "Zip"
    ==> "Default"

// start build
RunTargetOrDefault "Default"