NOTE: If you are reading this file inside Visual Studio, it's recommended to install the Markdown Editor.
NOTE: If you do not have access to the SkyKick nuget feed, a copy of the assemblies used in this workshops is located in the libs
folder.
This workshop does a deep dive on how to leverage Single Responsibility, Dependency Injection, Mocking and other Testing technologies to create or modify an application to make it highly testable and author highly valuable Unit, Cross Component and Scenario Tests.
The Workshop starts with a very simple application and goes step-by-step on how to refactor and redesign the following code so that we end up with a cleanly designed application with a regression test library and excellent code coverage:
static int CountWordsOnUrl(string url)
{
string html = string.Empty;
using (var webClient = new WebClient())
html = webClient.DownloadString(url);
var text = new CsQuery.CQ(html).Text();
return text.Split(' ').Length;
}
- Chapter 0 Create Initial PoC
- Chapter 1 Single Responsibility Refactor
- Chapter 2 Initial Tests
- Chapter 3 Dependency Injection with Ninject
- Chapter 4 TDD and the Regresstion Test Suite
- Chapter 5 Testing Error Handling Policy
- Chapter 6 Replacing Singletons with DI
- Chapter 7 Factories and File Input
- Chapter 8 BDD and Scenario Tests
-
Create an empty Solution called
SkyKick.NinjectWorkshop.WordCounting
-
Create a new Solution Folder called
V1
-
Inside
V1
Folder, create a new Console Application calledSkyKick.NinjectWorkshop.WordCounting.Prototype
-
Add a NuGet reference to
CsQuery 1.3.4
-
Add a Reference to
System.Net.Http
-
Update the Program.cs with the following code:
using System; using System.Net; namespace SkyKick.NinjectWorkshop.WordCounting.Prototype { class Program { static void Main(string[] args) { while (true) { Console.Write("Enter Url: "); var url = Console.ReadLine(); Console.WriteLine($"Number of words on [{url}]: {CountWordsOnUrl(url)}"); Console.WriteLine(); } } static int CountWordsOnUrl(string url) { string html = string.Empty; using (var webClient = new WebClient()) html = webClient.DownloadString(url); var text = new CsQuery.CQ(html).Text(); return text.Split(' ').Length; } } }
-
Run the Program.
-
Enter
https://www.skykick.com
-
Make sure that a word count is written to the screen
-
The Prototype application gets the initial job done, but it's not testable. The CountWordsOnUrl
method has too many responsibilities, it must know how to:
- Make a Http
Get
Request to a WebSite and receive its Response - Parsing Text from Html
- Counting the number of words in a String
To make this application more testable, we'll start by following the Single Responsibility Principle and break each Responsibility above into its own class.
Each class will be exposed to the broader system as an interface. This will allow us to easily mock behavior. Additionally, consumers will not need to be concerned with knowing about individual implementations, they will only declare the interface or contracts that they need in order for they themselves to to do their work. This principle is called Inversion of Control.
-
Create a new Solution Folder called
V2
-
Create a new Class Library project in the
V2
folder calledSkyKick.NinjectWorkshop.WordCounting
. This project will store all of the logic of the Word Counting application.- Add a NuGet reference to
SkyKick.Bcl.Logging
from the SkyKick nuget feed. This package provides theILogger
interface and has nice support for DI and Testing.
- Add a NuGet reference to
-
Create a new Console Application project in the
V2
folder calledSkyKick.NinjectWorkshop.WordCounting.UI
. This project will contain the Console UI used to interact with the Word Counting application.-
Add a reference to
SkyKick.NinjectWorkshop.WordCounting
-
Add a NuGet reference to
SkyKick.Bcl.Logging
from the SkyKick nuget feed
-
-
Create a new Class Library project in the
V2
folder calledSkyKick.NinjectWorkshop.WordCounting.Tests
. This project will contain Tests for bothSkyKick.NinjectWorkshop.WordCounting
andSkyKcik.NinjectWorkshop.WordCounting.UI
.-
Add a reference to
SkyKick.NinjectWorkshop.WordCounting
-
Add a reference to
SkyKick.NinjectWorkshop.WordCounting.UI
-
-
Move the Word Counting Algorithm to its own class.
-
Create a new file in
SkyKick.NinjectWorkshop.WordCounting
calledWordCountingAlgorithm
. -
This class will contain just the logic for counting the number of words in a string:
namespace SkyKick.NinjectWorkshop.WordCounting { public interface IWordCountingAlgorithm { int CountWordsInString(string content); } internal class WordCountingAlgorithm : IWordCountingAlgorithm { public int CountWordsInString(string content) { return content.Split(' ').Length; } } }
-
-
Move the code that reads from the Web to its own file.
-
NOTE:
This is a very important concept - we will wrap code that performs IO, especially static framework code and remove it from Logic code. This will allow us to write tests that mock out the IO call and fully test our Logic code. Additionally, from an academic sense, this encapsulation frees our Logic code from knowing the specific semantics of interacting with IO; though in practice the Logic will still need to be responsible for correctly interfacing with IO subsystems (via the wrappers) to handle things like retries and disposing. -
Create a new Folder in
SkyKick.NinjectWorkshop.WordCounting
calledHttp
. -
Create a new Class file called
WebClientWrapper
inHttp
:using System.Net; using System.Threading; using System.Threading.Tasks; using SkyKick.Bcl.Logging; namespace SkyKick.NinjectWorkshop.WordCounting.Http { public interface IWebClient { Task<string> GetHtmlAsync(string url, CancellationToken token); } internal class WebClientWrapper : IWebClient { private readonly ILogger _logger; public WebClientWrapper(ILogger logger) { _logger = logger; } public async Task<string> GetHtmlAsync(string url, CancellationToken token) { _logger.Debug($"Downloading [{url}]"); using (var client = new WebClient()) return await client.DownloadStringTaskAsync(url); } } }
-
-
Move the code that gets Text from a Website into its own file.
-
Add a NuGet reference to
CsQuery 1.3.4
toSkyKick.NinjectWorkshop.WordCounting
-
Create a new Class file called
WebTextSource
to the Http folder:using System.Threading; using System.Threading.Tasks; namespace SkyKick.NinjectWorkshop.WordCounting.Http { public interface IWebTextSource { Task<string> GetTextFromUrlAsync(string url, CancellationToken token); } internal class WebTextSource : IWebTextSource { private readonly IWebClient _webClient; public WebTextSource(IWebClient webClient) { _webClient = webClient; } public async Task<string> GetTextFromUrlAsync(string url, CancellationToken token) { var html = await _webClient.GetHtmlAsync(url, token); return new CsQuery.CQ(html).Text(); } } }
-
NOTE:
This class is using theIWebClient
that we created in the previous step so it doesn't directly interact withSystem.Net.Http.WebClient
. Also, we useIWebClient
in the Constructor Parameter instead of explictly refrencingWebClientWrapper
. Both of these design chocies will allow us to very easily mock out reading from a website when we start writing unit tests.
-
-
Combine the pieces into
WordCountingEngine
-
Create a new Class at the root of
SkyKick.NinjectWorkshop.WordCounting
calledWordCountingEngine
:using System.Threading; using System.Threading.Tasks; using SkyKick.Bcl.Logging; using SkyKick.NinjectWorkshop.WordCounting.Http; namespace SkyKick.NinjectWorkshop.WordCounting { public interface IWordCountingEngine { Task<int> CountWordsOnUrlAsync(string url, CancellationToken token); } internal class WordCountingEngine : IWordCountingEngine { private readonly IWebTextSource _webTextSource; private readonly IWordCountingAlgorithm _wordCountingAlgorithm; private readonly ILogger _logger; public WordCountingEngine( IWebTextSource webTextSource, IWordCountingAlgorithm wordCountingAlgorithm, ILogger logger) { _webTextSource = webTextSource; _wordCountingAlgorithm = wordCountingAlgorithm; _logger = logger; } public async Task<int> CountWordsOnUrlAsync(string url, CancellationToken token) { _logger.Debug($"Counting Words on [{url}]"); var text = await _webTextSource.GetTextFromUrlAsync(url, token); return _wordCountingAlgorithm.CountWordsInString(text); } } }
-
This class neatly ties together the
WordCountingAlgorithm
IWebTextSource
. It's Single Responsibility is to callIWebTextSource
and pass its output toWordCountingAlgoirthm
thus allowing both pieces to operate as independent units.
-
-
Create a Repl (Read Evaluate Print Loop) to parse UI input and invoke the
IWordCountingEngine
-
This externalizes the Responsibility of parsing user input out of
Program
, which will become responsible only for initializing the system. -
Create a new Class called
Repl
inSkyKick.NinjectWorkshop.WordCounting.UI
:using System; using System.Threading; using System.Threading.Tasks; namespace SkyKick.NinjectWorkshop.WordCounting.UI { internal class Repl { private readonly IWordCountingEngine _wordCountingEngine; public Repl(IWordCountingEngine wordCountingEngine) { _wordCountingEngine = wordCountingEngine; } public async Task RunAsync(CancellationToken token) { Console.Write("Enter Url: "); var url = Console.ReadLine(); var count = await _wordCountingEngine.CountWordsOnUrlAsync(url, token); Console.WriteLine($"Number of words on [{url}]: {count}"); Console.WriteLine(); } } }
-
-
Update Program to use
Repl
-
Replace the default code in
Program
with:using System.Threading; using SkyKick.Bcl.Logging.ConsoleTestLogger; using SkyKick.Bcl.Logging.Infrastructure; using SkyKick.Bcl.Logging.Log4Net; using SkyKick.NinjectWorkshop.WordCounting.Http; namespace SkyKick.NinjectWorkshop.WordCounting.UI { class Program { static void Main(string[] args) { var repl = new Repl( new WordCountingEngine( new WebTextSource( new WebClientWrapper( new ConsoleTestLogger( typeof(WebClientWrapper), new LoggerImplementationHelper()))), new WordCountingAlgorithm(), new ConsoleTestLogger( typeof(WordCountingEngine), new LoggerImplementationHelper()))); while (true) { repl.RunAsync(CancellationToken.None).Wait(); } } } }
-
Take a careful look at
new Repl(...)
. This isProgram
Single Responsiblity - initializing the object graph forRepl
. Because we have designed the class library based on Inversion of Control, we create the entire object graph forRepl
. We haven't yet introduced a Dependency Injection framework, but once we do, one of the primary benefits will be that we give DI a series of Bindings and it will take over creating this object graph.- This manual creation of the object graph is sometime refered to as "Poor Man's DI"
-
If you try to compile right now you'll get a compiler error because
WordCountingEngine
and the other concrete classes inSkyKick.NinjectWorkshop.WordCounting
are inaccessible because of their protection level.- Temporarily, update
SkyKick.NinjectWorkshop.WordCounting
AssemblyInfo.cs
to allowSkyKick.NinjectWorkshop.WordCounting.UI
to accessinternal
classes:[assembly: InternalsVisibleTo("SkyKick.NinjectWorkshop.WordCounting.UI")]
- We'll fix this later once we introduce Ninject; we'll be able to safely hide implementation classes with Ninject so we can enforce consumers of
SkyKick.NinjectWorkshop.WordCounting
are only allowed to reference interfaces.
- Temporarily, update
-
-
Run the Program.
-
Enter
https://www.skykick.com
-
Make sure that a word count is written to the screen
-
Now that we have applied Single Responsibility and broken apart the prototype into its constituent parts, lets take advantage of the design and create some Tests
In this section we'll create what we'ver termed a Cross Component Test. This is a Test built using a Unit Test Framework but rather than testing a single class or unit, it tests multiple classes working together. Writing a Unit Test for WordCountingEngine
that just verifies that it takes the output from WebTextSource
and passes it to WordCountingAlgorithm
would not be very valuable. Instead if we create a Cross Component Test that uses all of these classes together, but with a mocked IWebClient
to simulate a web response, we get a test that actually verifies behavior and is valuable.
-
Add NuGet Packages to
SkyKick.NinjectWorkshop.WordCounting.Tests
-
Add a NuGet reference to
NUnit 2.6.4
. -
Add a NuGet reference to
RhinoMocks 3.6.1
. -
Add a NuGet reference to
Should 1.1.20
. This Library adds fluent extensions compliementAssert
likeShouldEqual()
which we'll make use of in our tests. -
Add a NuGet reference to
SkyKick.Bcl.Logging
from the SkyKick nuget feed -
Add a NuGet reference to
SkyKick.Bcl.Extensions
from the SkyKick nuget feed
-
-
Allow access to Internals for Tests
-
Often Tests will need to access
internal
concrete implementations in order to test them. This is perfectly ok. -
Update
SkyKick.NinjectWorkshop.WordCounting
AssemblyInfo.cs
to allowSkyKick.NinjectWorkshop.WordCounting.Tests
to accessinternal
classes:[assembly: InternalsVisibleTo("SkyKick.NinjectWorkshop.WordCounting.Tests")]
-
-
Add a Sample Html File
-
The Cross Component Test we will write will simulate making a call to a web server using a mock of
IWebClient
and will expect html to comeback. So we'll add a file that contians that markup. -
Create a new Folder in
SkyKick.NinjectWorkshop.WordCounting.Tests
calledSampleFiles
-
Create a new Text File in
SampleFiles
calledTwoWordsHtml.txt
:<html><body>Hello World</body></html>
-
In the Solution Explorer, right click on
TwoWordsHtml.txt
and select Properties from the Context Menu. In the Properties Window, change the Build Action toEmbedded Resource
- This will add
TwoWordsHtml.txt
to the compiled Tests dll. UsingSkyKick.Bcl.Extensions
it will be very easy to read this file from a Test without having to worry about paths.
- This will add
-
-
Write
WordCountingEngineTests
-
Create a new Class at the root in
SkyKick.NinjectWorkshop.WordCounting.Tests
calledWordCountingEngineTests
:using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Rhino.Mocks; using Should; using SkyKick.Bcl.Extensions.Reflection; using SkyKick.Bcl.Logging.ConsoleTestLogger; using SkyKick.Bcl.Logging.Infrastructure; using SkyKick.NinjectWorkshop.WordCounting.Http; namespace SkyKick.NinjectWorkshop.WordCounting.Tests { /// <summary> /// Tests for <see cref="WordCountingEngineTests"/> /// </summary> [TestFixture] public class WordCountingEngineTests { /// <summary> /// Cross Component test that tests the happy path of /// <see cref="WordCountingEngine"/> counting the correct /// number of words on a web page using mocked Web Content /// </summary> [Test] [TestCase( "SkyKick.NinjectWorkshop.WordCounting.Tests.SampleFiles.TwoWordsHtml.txt", 2)] public async Task CountsWordsInSampleFilesCorrectly( string embeddedHtmlResourceName, int expectedCount) { // ARRANGE var fakeUrl = "http://testing.com/"; var fakeToken = new CancellationTokenSource().Token; var fakeWebContent = GetType().Assembly.GetEmbeddedResourceAsString(embeddedHtmlResourceName); var mockWebClient = MockRepository.GenerateMock<IWebClient>(); mockWebClient .Stub(x => x.GetHtmlAsync( Arg.Is(fakeUrl), Arg.Is(fakeToken))) .Return(Task.FromResult(fakeWebContent)); var wordCountingEngine = new WordCountingEngine( new WebTextSource( mockWebClient), new WordCountingAlgorithm(), new ConsoleTestLogger( typeof(WordCountingEngine), new LoggerImplementationHelper())); // ACT var count = await wordCountingEngine.CountWordsOnUrlAsync(fakeUrl, fakeToken); // ASSERT count.ShouldEqual(expectedCount); } } }
-
Run the
CountWordsInSampleFilesCorreclty
Test and verify it passes
-
-
Explore
WordCountingEngineTests
- There's a lot of important concepts here so lets explore them:
-
/// Tests for <see cref="WordCountingEngineTests"/>
{.language-csharp} - I like to add this to Test Fixtures to clearly indicate the primary class that will be tested. Additionally, using the<see cref=""/>
{.language-xml} makes it easy to navigate back to the main class. -
/// Cross Component test that tests ...
{.language-csharp} I like to add comments at the top of most tests to quickly describe what the test it meant to do. This makes it easier to maintain the test. -
[TestCase("TwoWordsHtml.txt", 2)]
{.language-csharp} This Attribute instructs NUnit to pass these input parameters toCountWordsInSampleFilesCorrectly
. This is a very important concept because it allows us to write a single Test body and have multiple[TestCase]
inputs.- This is the start of building a Regression Test Library. Later on we'll see how once we find a bug, we can add a new Sample File and then add a new [TestCase] to capture the bug and prove we've resolved it.
-
GetType().Assembly.GetEmbeddedResourceAsString(embeddedHtmlResourceName)
{.language-csharp} This is provided bySkyKick.Bcl.Extensions.Reflection
. It's a helper for loadingTwoWordsHtml.txt
. Having test input in a seperate file makes it easier to maintain and work with. When it comes to string test data, and especially large string test data, having a seperate file is very handy as it means you don't have to deal with odd whitespace or escaping quotes ("
) -
var mockWebClient = MockRepository.GenerateMock<IWebClient>();
{.language-csharp} Welcome to Rhino Mocks! The method create a dynamic proxy object implementation ofIWebClient
that allows us a number of powerful operations. We can stub out fake behaviors, inspect method arguments and a lot more.MockRepository.GenerateMock<>();
{.language-csharp}` is your entry point for creating this mocked objects.- It's technically possible to create a mock of a concrete objects that exposes virtual methods, but its a hell of a lot easier to use interfaces. This is one of the reasons why it's good practice to create an interface, even if you will only have one implementation.
-
.Stub(x => x.GetHtmlAsync(
{.language-csharp} This instructs Rhino Mocks on how to add a Behavior when ever anyone callsGetHtmlAsync
-
Arg.Is(fakeUrl)
{.language-csharp} In order to compile, a value must be passed in for ever method parameter needed byGetHtmlAsync
. Rhino Mocks offers theArg
class to help with this. Most commonly you can passArg<string>.Is.Anything
{.language-csharp}. This indicates to Rhino Mocks that this Behavior should trigger regardless of what the input is. However, for our case we add some extra verification in our test and say we want to ensure that theurl
passed toIWebClient.GetHtmlAsync
matches the one passed toWordCountingEngine.CountWordsOnUrlAsync
. IfWordCountingEngine
passes something other than_fakeUrl
, our test would fail. -
Return(Task.FromResult(fakeWebContent))
{.language-csharp} This is the key to our test. WhenWordCountingEngine.CountWordsOnUrlAsync()
{.language-csharp} calls our mockedIWebClient.GetHtmlAsync()
{.language-csharp} we returnfakeWebContent
!
-
-
new WordCountingEngine(new WebTextSource(mockWebClient) ...
{.language-csharp} We build up a full object graph forWordCountingEngine
only replacing theIWebClient
with ourmockWebClient
. This way we can test multiple classes. -
count.ShouldEqual(expectedCount)
{.language-csharp} This is functuatlly equivelant toAssert.AredEqual(count, expectedCount)
{.language-csharp}, but I find the extension methods provided by theShould
library to be easier to read and better express intent. -
// ARRANGE
{.language-csharp} Arrange-Act-Assert, or AAA for short, is a common convention for organizing a Unit Test and is good practice. Using it improves the readability and maintainability of your tests. Part of the convnetion includes labeling the different sections with a comment.- Arrange - The series of steps necessary to initialize the Class Under Test. This includes defining Fakes, creating Mocks and creating an instance of the Class Under Test.
- Act - Perform the action that is to be tested. Often this is invoking a method on the Class Under Test. Be wary if you find that you are writing a substantial amount of code in this section. This could mean that you're test is trying to perform too many actions and should be broken into smaller tests or should be a Scenario Test (we'll cover that later) or that you've violated Single Responsibility and you have a class that is doing too many things.
- Assert - Validate the result (ie return value) of the Act section and any expected or not-expected side effects (ie calling to a database or throwing an exception).
-
Fakes vs Mocks vs Stubs - These are terms used to describe different types of variables in a Test and are often prepending to the variable name. There is disagrement by different experts and frameworks on how the terms should be used: https://stackoverflow.com/questions/346372/whats-the-difference-between-faking-mocking-and-stubbing. Here's how I use the terms:
- Fakes - Dummy data that will be fed to the Class Under Test that either contains no behavior (in the case of data) or, in the case of a class dependency, contains unverifiable behavior, because verifying the behavior would not be valuable. For example, I might implement a
FakeRepository
that impmements anIRepository
interface, but is just a wrapper around aList
. - Mocks - A proxy class that implements an interface and is generated by Rhino Mocks. Mocks have Behavior defined using methods like
.Stub()
and.Expecte()
and you can verify the Class Under Test has interacted with the Mock (iewordCountingEngine
called_mockWebClient.GetHtmlAsync
) - Stubs - I don't use this term. Often the difference between Mocks and Stubs offered by industry experts or mocking frameworks is the difference is whether or not Behavior or meant to be verified. In practice I have not found it valuable to differentiate.
- Fakes - Dummy data that will be fed to the Class Under Test that either contains no behavior (in the case of data) or, in the case of a class dependency, contains unverifiable behavior, because verifying the behavior would not be valuable. For example, I might implement a
-
- There's a lot of important concepts here so lets explore them:
Summary Our hard work has paid off! We've taken an untestable application and used SOLID principles to write highly testable code. And we've proven it by writing an extensible Cross Component test that can be used to start a Regression Test Suite!
We've refactored our code and it's highly testable. But, using "Poor Man's DI" we're left to build the Object Graph ourselves:
var repl =
new Repl(
new WordCountingEngine(
new WebTextSource(
new WebClientWrapper(
new ConsoleTestLogger(
typeof(WebClientWrapper),
new LoggerImplementationHelper()))),
new WordCountingAlgorithm(),
new ConsoleTestLogger(
typeof(WordCountingEngine),
new LoggerImplementationHelper())));
Even with only a few classes this already unwieldly. Imagine having 100s or 1000s of classes; this would not be sustainable.
The primary benefit of using a Dependency Injection framework like Ninject, is it provides tooling so that we don't have to build up this Object Graph.
-
Building a Kernel
-
Add a NuGet reference to
Ninject 3.2.2.0
toSkyKick.NinjectWorkshop.WordCounting.UI
if it hasn't already been added. -
Create a new Class at the root of
SkyKick.NinjectWorkshop.WordCounting.UI
calledStartup
:using Ninject; namespace SkyKick.NinjectWorkshop.WordCounting.UI { public class Startup { public IKernel BuildKernel() { return new StandardKernel(); } } }
-
Note that this is not
static
. There is no reason for this method to bestatic
and in fact, marking it static could be a deteriment to testability, as we'll see later on. -
The name
Startup
is not strictly necessary. It's a convention that I was first expsoed to in ASP.NET Mvc and have since adopted. I like puting theBuildKernel
method in a class calledStartup
because it clearly indicates that this it should only be invoked at Startup and should not be called by any application code, other than the code related to starting up the application.
-
-
Update
Program.cs
to useStartup.BuildKernel
using System.Threading; using Ninject; namespace SkyKick.NinjectWorkshop.WordCounting.UI { class Program { static void Main(string[] args) { var kernel = new Startup().BuildKernel(); var repl = kernel.Get<Repl>(); while (true) { repl.RunAsync(CancellationToken.None).Wait(); } } } }
-
We've now delegated building
Repl
to Ninject! -
Important: Deciding where to build and access a Kernel is a very important design decision. It should ONLY be done at the Entry Point of an application. For a Cloud Service, that's in
RoleEntryPoint
. For a Console Application, that's inProgram.Main
For Web Applications (asp.net mvc, or api), there's a specialized plugin that automatically plugs in to the ASP.NET Framework's Controller Factory so that you should never access the Kernel at all.- This can be difficult in code bases that were not designed with Inversion of Control and it may be necessary to build and use the Kernel deeper in the stack. However, once a Kernel is built and used it should not be referenced lower in the stack.
- Designing classes that take a dependency of the Kernel is a (anti-)pattern known as Service Locator. In this design each class is passed the Kernel and they use the Kernel to resolve their dependencies themselves. Service Locator is bad. This is discussed at greater detail below in an Appendix.
-
-
-
Run
SkyKick.NinjectWorkshop.WordCounting.UI
-
You should immediately get an error like:
Ninject.ActivationException: 'Error activating IWordCountingEngine No matching bindings are available, and the type is not self-bindable. Activation path: 2) Injection of dependency IWordCountingEngine into parameter wordCountingEngine of constructor of type Repl 1) Request for Repl Suggestions: 1) Ensure that you have defined a binding for IWordCountingEngine. 2) If the binding was defined in a module, ensure that the module has been loaded into the kernel. 3) Ensure you have not accidentally created more than one kernel. 4) If you are using constructor arguments, ensure that the parameter name matches the constructors parameter name. 5) If you are using automatic module loading, ensure the search path and filters are correct.
-
There is a problem and Ninject is trying to be helpful. It was asked to build
Repl
, butRepl
takes a dependency onIWordCountingEngine
. Ninject doesn't know how to build aIWordCountingEngine
. We need to tell Ninject which concrete type to build when someone asks for aIWordCountingEngine
.
-
-
Add a simple binding:
-
Update
Startup
:using Ninject; using SkyKick.NinjectWorkshop.WordCounting; namespace SkyKick.NinjectWorkshop.WordCounting.UI { public class Startup { public IKernel BuildKernel() { var kernel = new StandardKernel(); kernel.Bind<IWordCountingEngine>().To<WordCountingEngine>(); return kernel; } } }
- This tells Ninject that whenever anyone needs a
IWordCountingEngine
, build aWordCountingEngine
and give them that instance.
- This tells Ninject that whenever anyone needs a
-
Run
SkyKick.NinjectWorkshop.WordCounting.UI
i. The Exception message has now changed, and Ninject has run into the next type it doesn't know how to build.
-
-
Ninject Modules
-
Adding all of the necessary bindings by hand will be labor intensive and it's easy to forget to add a binding if you add a new class. Fortunatly, if we use the convention
Foo
implementsIFoo
we can leverage that convention to automatically add all the bindings! -
Add a NuGet reference to
Ninject.Extensions.Conventions 3.2.0.0
toSkyKick.NinjectWorkshop.WordCounting
-
Add a new Class to the root of
SkyKick.NinjectWorkshop.WordCounting
calledNinjectModule
:using Ninject.Extensions.Conventions; using SkyKick.NinjectWorkshop.WordCounting.Http; namespace SkyKick.NinjectWorkshop.WordCounting { public class NinjectModule : Ninject.Modules.NinjectModule { public override void Load() { Kernel.Bind(x => x.FromThisAssembly() .IncludingNonePublicTypes() .SelectAllClasses() .BindDefaultInterface()); } } }
- One or more
NinjectModule
can be passed to theStandardKernel
constructor and adds bindings. - Using
Ninject.Extensions.Conventions
we can add our default bindings - all classes that follow the naming conventionFoo
implementsIFoo
will automatically bind. - By using
IncludingNonePublicTypes()
internal
classes will be bound as well. This means we no longer need to leak internal types toSkyKick.NinjectWorkshop.WordCounting.UI
- One or more
-
Update the
AssemblyInfo
class inSkyKick.NinjectWorkshop.WordCounting.Properties
and remove the line:[assembly: InternalsVisibleTo("SkyKick.NinjectWorkshop.WordCounting.UI")]
-
-
Update
Startup.BuildKernel
-
Add the new NinjectModule to
Startup.BuildKernel
:using Ninject; namespace SkyKick.NinjectWorkshop.WordCounting.UI { public class Startup { public IKernel BuildKernel() { return new StandardKernel( new SkyKick.NinjectWorkshop.WordCounting.NinjectModule()); } } }
- Note the use of the full namespace for referencing the
NinjectModule
. I find this to be quite helpful as you'll often be pulling in multiple Modules, and they are all calledNinjectModule
.
- Note the use of the full namespace for referencing the
-
-
Run
SkyKick.NinjectWorkshop.WordCounting.UI
- We still get a Ninject Exception, but we've gotten a lot further. If we look at the exception message
IWebClient
was not bound. The implementation class is calledWebClientWrapper
. It doesn't follow the convention, so we'll need to manually add a binding.
- We still get a Ninject Exception, but we've gotten a lot further. If we look at the exception message
-
Before we go any futher, lets TDD this problem by creating a Test to verify Bindings
- Update the
AssemblyInfo
class inSkyKick.NinjectWorkshop.WordCounting.UI.Properties
and add the line:[assembly: InternalsVisibleTo("SkyKick.NinjectWorkshop.WordCounting.Tests")]
- Create a new Class in the root of
SkyKick.NinjectWorkshop.WordCounting.Tests
calledNinjectBindingTests
:using Ninject; using NUnit.Framework; using Should; using SkyKick.NinjectWorkshop.WordCounting.UI; namespace SkyKick.NinjectWorkshop.WordCounting.Tests { /// <summary> /// Validates the Bindings in /// <see cref="Startup.BuildKernel"/> /// </summary> [TestFixture] public class NinjectBindingTests { /// <summary> /// <see cref="Repl"/> is the DI entry /// point used by <see cref="Program.Main"/>, so /// verify all dependencies are correctly bound. /// </summary> [Test] public void CanLoadRepl() { // ARRANGE var kernel = new Startup().BuildKernel(); // ACT var repl = kernel.Get<Repl>(); // ASSERT repl.ShouldNotBeNull(); } } }
- This is a very simple but powerful test that will confirm our bindings are not working.
- Run the
CanLoadRepl
and confirm that it fails.
- Update the
-
Update
NinjectModule
with a binding forIWebClient
using Ninject.Extensions.Conventions; using SkyKick.NinjectWorkshop.WordCounting.Http; namespace SkyKick.NinjectWorkshop.WordCounting { public class NinjectModule : Ninject.Modules.NinjectModule { public override void Load() { Kernel.Bind(x => x.FromThisAssembly() .IncludingNonePublicTypes() .SelectAllClasses() .BindDefaultInterface()); Kernel.Bind<IWebClient>().To<WebClientWrapper>(); } } }
-
Run the
CanLoadRepl
Test i. It still fails, but we're almost there! This time we get an Exception trying to find a Binding forSkyKick.Bcl.Logging.ILogger
-
The
SkyKick.Bcl.Logging
includes aNinjectModule
that we can use. Add it toStartup.BuildKernel()
:using Ninject; namespace SkyKick.NinjectWorkshop.WordCounting.UI { public class Startup { public IKernel BuildKernel() { return new StandardKernel( new SkyKick.Bcl.Logging.ConsoleTestLogger.NinjectModule(), new SkyKick.NinjectWorkshop.WordCounting.NinjectModule()); } } }
-
Run the
CanLoadRepl
Test- Test should now pass!!
-
Run the Program to confirm
-
Enter
https://www.skykick.com
-
Make sure that a word count is written to the screen
-
-
Finally, let's update
WordCountingEngineTests
so it too can use Ninject instead of building an Object Graph forWordCountingEngine
:using System.Threading; using System.Threading.Tasks; using Ninject; using NUnit.Framework; using Rhino.Mocks; using Should; using SkyKick.Bcl.Extensions.Reflection; using SkyKick.Bcl.Logging.ConsoleTestLogger; using SkyKick.Bcl.Logging.Infrastructure; using SkyKick.NinjectWorkshop.WordCounting.Http; using SkyKick.NinjectWorkshop.WordCounting.UI; namespace SkyKick.NinjectWorkshop.WordCounting.Tests { /// <summary> /// Tests for <see cref="WordCountingEngineTests"/> /// </summary> [TestFixture] public class WordCountingEngineTests { /// <summary> /// Cross Component test that tests the happy path of /// <see cref="WordCountingEngine"/> counting the correct /// number of words on a web page using mocked Web Content /// </summary> [Test] [TestCase( "SkyKick.NinjectWorkshop.WordCounting.Tests.SampleFiles.TwoWordsHtml.txt", 2)] public async Task CountsWordsInSampleFilesCorrectly( string embeddedHtmlResourceName, int expectedCount) { // ARRANGE var fakeUrl = "http://testing.com/"; var fakeToken = new CancellationTokenSource().Token; var fakeWebContent = GetType().Assembly.GetEmbeddedResourceAsString(embeddedHtmlResourceName); var kernel = new Startup().BuildKernel(); var mockWebClient = MockRepository.GenerateMock<IWebClient>(); mockWebClient .Stub(x => x.GetHtmlAsync( Arg.Is(fakeUrl), Arg.Is(fakeToken))) .Return(Task.FromResult(fakeWebContent)); kernel.Rebind<IWebClient>().ToConstant(mockWebClient); var wordCountingEngine = kernel.Get<WordCountingEngine>(); // ACT var count = await wordCountingEngine.CountWordsOnUrlAsync(fakeUrl, fakeToken); // ASSERT count.ShouldEqual(expectedCount); } } }
- We need to use
Rebind<IWebClient>()
to override the existing binding forIWebClient
that's already in the Kernel. - We can use
.ToConstant
to tell Ninject that we want to use a pre-exisiting instance (our mock) instead of having Ninject build anything for us. - Because we ave manipulating the bindings on the Kernel it's very important that the BuildKernel() is not static and returns a new Kernel on every call. Otherwise, if we had multiple tests that were manipulating bindings our tests could interfere with each other.
- We need to use
-
Run
CountsWordsInSampleFilesCorrectly
and confirm the test passes.
We have a pretty good application at this point; we're using SOLID design principles and have 86% Test Coverage of SkyKick.NinjectWorkshop.WordCounting
!
But our QA team found a bug! A web site with a certain type of html is tripping up WordCountingAlgorithm
. So let's TDD the problem and expand our Regression Test Suite
-
Create a new Text File in
SampleFiles
calledWordsWithEntersAndNoSpaces.txt
:<html> <body> One Two Thre </body> </html>
- Double check there aren't any spaces at the end of the words in
WordsWithEntersAndNoSpaces.txt
- In the Solution Explorer, right click on
WordsWithEntersAndNoSpaces.txt
and select Properties from the Context Menu. In the Properties Window, change the Build Action toEmbedded Resource
- Double check there aren't any spaces at the end of the words in
-
Add the new
TestCase
toWordCountingEngineTests.CountsWordsInSampleFilesCorrectly
:[Test] [TestCase( "SkyKick.NinjectWorkshop.WordCounting.Tests.SampleFiles.TwoWordsHtml.txt", 2)] [TestCase( "SkyKick.NinjectWorkshop.WordCounting.Tests.SampleFiles.WordsWithEntersAndNoSpaces.txt", 3)] public async Task CountsWordsInSampleFilesCorrectly( string embeddedHtmlResourceName, int expectedCount)
-
Run the new Test Case and verify it fails
-
Now that we have a failing test and have proved the bug, lets fix
WordCountingAlgorithm
:using System; namespace SkyKick.NinjectWorkshop.WordCounting { public interface IWordCountingAlgorithm { int CountWordsInString(string content); } internal class WordCountingAlgorithm : IWordCountingAlgorithm { public int CountWordsInString(string content) { return content .Replace("\n", " ") .Split(new []{' '}, StringSplitOptions.RemoveEmptyEntries) .Length; } } }
-
Run all Test Cases for CountsWordsInSampleFilesCorrectly and verify the Test passes proving the bug is fixed, and we didn't introduce a regression!
You just TDD'd a bug and expanded the Regression Test Library!
Currently our application doesn't have any error handling policy. Lets add one in and see how it can be tested.
Lets add the requirement that
- If the
IWebClient
throws a general exception or gets a 500, we should retry 3 times with a back off period of 0.5s, 1s and 10s. - If the
IWebClient
gets any http error code other than a 500, we should fail immediately and not perform a retry.
-
Add a NuGet reference to
Polly 5.3.0
toSkyKick.NinjectWorkshop.WordCounting
-
Create a new Class called
WebTextSourceOptions
inSkyKick.NinjectWorkshop.WordCounting.Http
:using System; namespace SkyKick.NinjectWorkshop.WordCounting.Http { public class WebTextSourceOptions { public TimeSpan[] RetryTimes { get; set; } = new[] { TimeSpan.FromSeconds(0.5), TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10) }; } }
- It's ok for
WebTextSourceOptions
to include default values in a real system.
- It's ok for
-
Update
WebTextSource
to add retry logic:using System; using System.Net; using System.Threading; using System.Threading.Tasks; using Polly; namespace SkyKick.NinjectWorkshop.WordCounting.Http { public interface IWebTextSource { Task<string> GetTextFromUrlAsync(string url, CancellationToken token); } internal class WebTextSource : IWebTextSource { private readonly IWebClient _webClient; private readonly WebTextSourceOptions _options; public WebTextSource(IWebClient webClient, WebTextSourceOptions options) { _webClient = webClient; _options = options; } public async Task<string> GetTextFromUrlAsync(string url, CancellationToken token) { var policy = Polly.Policy .Handle<WebException>(webException => (webException.Response as HttpWebResponse)?.StatusCode == HttpStatusCode.InternalServerError) .Or<Exception>() .WaitAndRetryAsync(_options.RetryTimes); var html = await policy.ExecuteAsync( _ => _webClient.GetHtmlAsync(url, token), token); return new CsQuery.CQ(html).Text(); } } }
-
We could add retry to
WebClientWrapper
, but we want wrappers to be very light weight, they really shouldn't include any additional logic ontop of the api code they wrap. -
Note how
WebTextSourceOptions
is injected. This meansWebTextSource
is not responsible for knowing how to get its own settings, it must be injected. This also gives us greater flexiblity for testing.- This pattern aligns very nicely with
SkyKick.Bcl.Configuration
and the new Configuration system in .net Core which provides a DI supported subsytem for configuration
- This pattern aligns very nicely with
-
Note: on the
ExecuteAsync
lambda, the _ for the lambda parameter. This is short hand indicating that the variable (CancellationToken
) wont be used.
-
-
Create a new Folder called Http in
SkyKick.NinjectWorkshop.WordCounting.Tests
-
Create a new Class called
WebTextSourceTests
inSkyKick.NinjectWorkshop.WordCounting.Tests.Http
:using System; using System.Collections; using System.Net; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Rhino.Mocks; using Should; using SkyKick.NinjectWorkshop.WordCounting.Http; using SkyKick.Bcl.Extensions.Reflection; namespace SkyKick.NinjectWorkshop.WordCounting.Tests.Http { /// <summary> /// Tests for <see cref="WebTextSource"/> /// </summary> [TestFixture] public class WebTextSourceTests { public IEnumerable InvokesRetryPolicyExceptions() { yield return new object[] { new Exception("General Exception should be retried"), true }; yield return new object[] { CreateWebExceptionWithStatusCode(HttpStatusCode.InternalServerError), // retry on a 500 true }; yield return new object[] { CreateWebExceptionWithStatusCode(HttpStatusCode.NotFound), // do not retry on 404 false }; } /// <summary> /// <see cref="WebTextSource"/> will retry on certain /// exceptions but not others. Verifies when <see cref="IWebClient"/> /// throws <paramref name="webClientException"/> that the /// retry policy is invoked if <paramref name="expectRetry"/>. This /// is verified by counting the number of times /// <see cref="IWebClient.GetHtmlAsync"/> is called. /// </summary> [Test] [TestCaseSource(nameof(InvokesRetryPolicyExceptions))] public async Task InvokesRetryPolicyOnErrors(Exception webClientException, bool expectRetry) { // ARRANGE var fakeWebTextSourceOptions = new WebTextSourceOptions { RetryTimes = new[] { TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(0) } }; var fakeUrl = "http://testing.com"; var fakeToken = new CancellationTokenSource().Token; var mockWebClient = MockRepository.GenerateMock<IWebClient>(); mockWebClient .Expect(x => x.GetHtmlAsync(Arg.Is(fakeUrl), Arg.Is(fakeToken))) .Throw(webClientException) .Repeat.Times( // 1 for initial call and then any retries 1 + (expectRetry ? fakeWebTextSourceOptions.RetryTimes.Length : 0)); var webTextSource = new WebTextSource(mockWebClient, fakeWebTextSourceOptions); // ACT try { await webTextSource.GetTextFromUrlAsync(fakeUrl, fakeToken); Assert.Fail("Expected an exception to be thrown but was not."); } catch (Exception e) { // ASSERT e.ShouldEqual(webClientException); mockWebClient.VerifyAllExpectations(); } } /// <summary> /// Have to use reflection to build <see cref="WebException"/> /// because Microsoft doesn't provide public constructors / setters /// <para /> /// This leverages tools from <see cref="SkyKick.Bcl.Extensions.Reflection"/> /// to make it a bit easier. /// </summary> private WebException CreateWebExceptionWithStatusCode(HttpStatusCode status) { var httpWebResponse = (HttpWebResponse) Activator.CreateInstance( typeof(HttpWebResponse), false); typeof(HttpWebResponse) .CreateFieldAccessor<HttpStatusCode>("m_StatusCode") .Set(httpWebResponse, status); var webException = new WebException(""); typeof(WebException) .CreateFieldAccessor<WebResponse>("m_Response") .Set(webException, httpWebResponse); return webException; } } }
-
Normally it would be very hard to test a retry policy based on an exception thrown by a 3rd party/framework utility, but because we have a wrapper and
WebTextSourceOptions
, it's quite easy. -
Use
[TestCaseSouce]
to point to a method that generates test input. This allows us to run code to generate Test Cases that wouldn't be possible with just [TestCase]. This allows our test code to test a single hypothesis (specific exception triggers retry) while still maximizing code reuse. -
Use .Expect() to have the ability to Verify that method was called with given method parameters a set number of times.
-
Use .Throw() to easily have a mock throw an exception
-
We create a
WebTextSourceOptions
with an array of 0 second retry times toVerify()
that the retry policy is retrying Web Requests -
Use
VerifyAllExpectations()
to verifyGetHtmlAsync
was called the correct number of times
-
-
Run
InvokesRetryPolicyOnErrors
Tests-
One of the Test Cases fails! We just found a bug in the retry logic - it retries on a non-transient exception. That would have been very very hard to identify in a running system!
-
The Exception that is logged is quiet daunting. We caught an exception, but it's not the exception we thought it would be, so the
ShouldEqual(webClientException)
threw a new exception. TheActual
exception is what was thrown by theWebTextSource
: ANullReferenceException
.-
This is a very important exception to understand when working with Mocks, especially when dealing with Async code.
-
Key to understanding is knowing how a Mock behaves by default, which is it will return
default()
for any method that has not been stubbed with eitherStub()
orExpect()
. When we an Async method is called on a Mock with no Stub, Rhino will return null, and the code will end up trying toawait null
which leades to theNullReferenceException.
-
-
-
Update InvokesRetryPolicyOnErrors to use a Strict Mock:
var mockWebClient = MockRepository.GenerateStrictMock<IWebClient>();
-
Re-Run
InvokesRetryPolicyOnErrors
Tests- We now get a better
Exception
in the Actual output aExpectationViolationException
. Using Strict mocks will have Rhino throw a very specificException
if the code under test tries to invoke a method that hasn't been stubbed. This is quite useful for helping to diagnose failing tests that use mocks.
- We now get a better
-
Update
WebTextSource
:var policy = Polly.Policy .Handle<WebException>(webException => (webException.Response as HttpWebResponse)?.StatusCode == HttpStatusCode.InternalServerError) .Or<Exception>(ex => !(ex is WebException)) .WaitAndRetryAsync(_options.RetryTimes);
-
Re-Run
InvokesRetryPolicyOnErrors
Tests- Everything should pass. You just diagnosed and fixed a retry policy bug completly in unit tests, before your code ever made it to prod!
Performance optimization time. We expect our Word Counter will be asked to count the same url over and over again. So to speed performance, we'll add a cache. But there's a catch, the cache we will use has a start up penalty. Before DI we'd use the Singleton pattern to make sure we only instantiated one instance of the cache so we'd only get hit with the penalty once. But we can use Ninject to replace the Singleton pattern ensuring we only get one instance of the cache. This eliminates the need for making the class static and results in highly testable code!
-
Create a new Folder in
SkyKick.NinjectWorkshop.WordCounting
calledThreading
-
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.Threading
calledThreadSleeper
:using System; using System.Threading; namespace SkyKick.NinjectWorkshop.WordCounting.Threading { public interface IThreadSleeper { void Sleep(TimeSpan timeToSleep); } internal class ThreadSleeper : IThreadSleeper { public void Sleep(TimeSpan timeToSleep) { Thread.Sleep(timeToSleep); } } }
-
Create a new Folder in
SkyKick.NinjectWorkshop.WordCounting
calledCache
-
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.Cache
calledWordCountCache
:using System; using System.Collections.Generic; using SkyKick.Bcl.Logging; using SkyKick.NinjectWorkshop.WordCounting.Threading; namespace SkyKick.NinjectWorkshop.WordCounting.Cache { public interface IWordCountCache { bool TryGet(string key, out int value); void Add(string key, int value); } internal class WordCountCache : IWordCountCache { private readonly Dictionary<string, int> _cache = new Dictionary<string, int>(); private readonly ILogger _logger; private readonly IThreadSleeper _threadSleeper; public WordCountCache(ILogger logger, IThreadSleeper threadSleeper) { _logger = logger; _threadSleeper = threadSleeper; } public bool TryGet(string key, out int value) { EnsureInitialized(); var cacheHit = _cache.TryGetValue(key, out value); _logger.Info( (cacheHit ? "Cache Hit" : "Cache Miss") + $": {key}"); return cacheHit; } public void Add(string key, int value) { EnsureInitialized(); _cache[key] = value; } private bool _isInitialized; private void EnsureInitialized() { if (_isInitialized) return; _logger.Warn("Initializing Cache"); _threadSleeper.Sleep(TimeSpan.FromSeconds(3)); _isInitialized = true; } } }
- Note how we use
IThreadSleeper
to wrap the call toThread.Sleep
. While this might seem a bit extereme, it's very helpful in enabling us to write a unit test that doesn't rely on a call tryTryGet()
taking a long time.
- Note how we use
-
Update
WordCountingEngine
to useIWordCountCache
:using System.Threading; using System.Threading.Tasks; using SkyKick.Bcl.Logging; using SkyKick.NinjectWorkshop.WordCounting.Cache; using SkyKick.NinjectWorkshop.WordCounting.Http; namespace SkyKick.NinjectWorkshop.WordCounting { public interface IWordCountingEngine { Task<int> CountWordsOnUrlAsync(string url, CancellationToken token); } internal class WordCountingEngine : IWordCountingEngine { private readonly IWebTextSource _webTextSource; private readonly IWordCountingAlgorithm _wordCountingAlgorithm; private readonly IWordCountCache _wordCountCache; private readonly ILogger _logger; public WordCountingEngine( IWebTextSource webTextSource, IWordCountingAlgorithm wordCountingAlgorithm, ILogger logger, IWordCountCache wordCountCache) { _webTextSource = webTextSource; _wordCountingAlgorithm = wordCountingAlgorithm; _logger = logger; _wordCountCache = wordCountCache; } public async Task<int> CountWordsOnUrlAsync(string url, CancellationToken token) { _logger.Debug($"Counting Words on [{url}]"); int wordCount; if (_wordCountCache.TryGet(url, out wordCount)) return wordCount; var text = await _webTextSource.GetTextFromUrlAsync(url, token); wordCount = _wordCountingAlgorithm.CountWordsInString(text); _wordCountCache.Add(url, wordCount); return wordCount; } } }
-
Run the
SkyKick.Ninject.Workshop.WordCounting.UI
-
Enter
https://www.skykick.com
. Note the log message that the Cache is initializing and the program waits for 3 seconds. -
Enter https://www.skykick.com again. Note how there is no log message about initialization and instead we get a log message about a cache hit.
-
The .UI program is not running multithreaded and the way it's designed, the
Repl
class keeps the full object graph between user input so it's ok thatWordCountCache
is not actually a singleton.
-
-
Create a Guard Test for
WordCountCache
-
Event though
SkyKick.NinjectWorkshop.WordCounting.UI
isn't using the cache from multiple requests,SkyKick.NinjectWorkshop.WordCounting
might need to support more advanced scenarios in the future, so we want to document that it should be created as a Singleton. We'll create a Guard Test - a quick test that protects a small but very important implementation detail against modification. -
Create a new Folder in
SkyKick.NinjectWorkshop.WordCounting.Tests
calledCache
-
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.Tests.Cache
calledWordCountCacheTests
:using System; using Ninject; using NUnit.Framework; using Rhino.Mocks; using Should; using SkyKick.Bcl.Logging; using SkyKick.NinjectWorkshop.WordCounting.Cache; using SkyKick.NinjectWorkshop.WordCounting.Threading; namespace SkyKick.NinjectWorkshop.WordCounting.Tests.Cache { /// <summary> /// Tests for <see cref="WordCountCache"/> /// </summary> [TestFixture] public class WordCountCacheTests { /// <summary> /// Makes sure that requesting multiple instances of /// <see cref="WordCountCache"/> does not require multiple /// calls to <see cref="WordCountCache.EnsureInitialized"/>. We also /// validate that the cache shares values between multiple instances. /// /// We can leverage the fact that <see cref="WordCountCache.EnsureInitialized"/> /// class <see cref="IThreadSleeper"/> as a proxy to count the number of /// <see cref="WordCountCache.EnsureInitialized"/>. /// /// As an added bonus, we can also make sure we log every time the cache /// is Initialized. /// </summary> [Test] public void WordCountCacheShouldBeBoundAsASingleton() { // ARRANGE var fakeKey = "fake"; var fakeValue = 5; var kernel = new StandardKernel( new SkyKick.NinjectWorkshop.WordCounting.NinjectModule()); var mockLogger = MockRepository.GenerateMock<ILogger>(); mockLogger .Expect(x => x.Warn( // test will fail if logging code in WordCountCache changes Arg.Is("Initializing Cache"), // optional parameter, but have to pass a value // or RhinoMocks will throw exception Arg<LoggingContext>.Is.Null)) .Repeat.Once(); var mockThreadSleeper = MockRepository.GenerateMock<IThreadSleeper>(); mockThreadSleeper .Expect(x => x.Sleep(Arg<TimeSpan>.Is.Anything)) .Repeat.Once(); kernel.Bind<ILogger>().ToConstant(mockLogger); kernel.Rebind<IThreadSleeper>().ToConstant(mockThreadSleeper); // ACT kernel.Get<IWordCountCache>().Add(fakeKey, fakeValue); int outValue; var containsKey = kernel.Get<IWordCountCache>() .TryGet(fakeKey, out outValue); // ASSERT containsKey.ShouldBeTrue(); outValue.ShouldEqual(fakeValue); mockLogger.VerifyAllExpectations(); mockThreadSleeper.VerifyAllExpectations(); } } }
-
Because we are testing a component of
SkyKick.NinjectWorkshop.WordCounting
, it's not really appropriate or necessary to useStartup().BuildKernel()
, so we'll create a new Kernel, using only the modules necessary to buildWordCountCache
. -
Note that when stubbing a method that has optional parameters, like for
ILogger.Warn
it's always necessary to pass Arg values for the optional parameters, otherwise RhinoMocks will throw an exception. -
We can Bind mocks to a StandardKernel for our test and Ninject is perfectly happy.
-
However, for
IThreadSleeper
we must useRebind()
. theSkyKick.NinjectWorkshop.WordCounting.NinjectModule
already has a binding forIThreadSleeepr
. If we useBind<IThreadSleeper>.ToConstant(mockThreadSleeper)
the call will succeed, however when we do akernel.Get()
Ninject will throw an exception because it will not know which of the two bindings to use. -
There is no problem if you use
Rebind
if there is not an existing binding.
-
-
Note how it's very useful to have a wrapper around
Thread.Sleep
, it allows the test to run in a fraction of a second, instead of waiting three seconds for the Initialize methods to complete. -
Because we're using quantum logging that supports DI, we can also verify that logging occurs :)
-
-
-
Run the
WordCountCacheShouldBeBoundAsASingleton
Test and confirm that it fails. Ninject is exhibiting default behavior, each call tokernel.Get<IWordCountCache>()
will return a new instance. -
Update
SkyKick.NinjectWorkshop.WordCounting.NinjectModule
:using Ninject; using Ninject.Extensions.Conventions; using SkyKick.NinjectWorkshop.WordCounting.Cache; using SkyKick.NinjectWorkshop.WordCounting.Http; namespace SkyKick.NinjectWorkshop.WordCounting { public class NinjectModule : Ninject.Modules.NinjectModule { public override void Load() { Kernel.Bind(x => x.FromThisAssembly() .IncludingNonePublicTypes() .SelectAllClasses() .BindDefaultInterface()); Kernel.Bind<IWebClient>().To<WebClientWrapper>(); Kernel.Rebind<IWordCountCache>().To<WordCountCache>().InSingletonScope(); } } }
-
The InSingletonScope instructs Ninject to only create one instance of a class on the first request and then reuse it for all subsequent requests.
-
Notice how we have to use
Rebind
in this case, because theSelectAllClasses().BindDefaultInterface()
will include a default binding forIWordCountCache
that we'll need to replace.
-
-
Re-Run the
WordCountCacheShouldBeBoundAsASingleton
Test-
Confirm the test now passes!
-
An interesting thing to note, is we only set
InSingletonScope()
whenIWordCountCache
is requested. If you were to change the test to requestWordCountCache
it would again fail because Ninject would create two different instances for the request toGet<WordCountCache>()
.- This can be fixed by adding
Bind<WordCountCache>().ToSelf().InSingletonScope()
in the Ninjet Module.-
Note the use of
.ToSelf()
, this is done instead ofBind<WordCountCache>().To<WordCountCache>()
-
That fixes the case if both requests are for
Get<WordCountCache>()
. But what if one request wasGet<IWordCountCache>()
and the other wasGet<WordCountCache>()
? Then it would fail, because Ninject sees each request as different, with differentInSingletonScopes()
bindings. To solve this is certainly possible, but requires more advanced bindings:Kernel.Bind<WordCountCache>().To<WordCountCache>().InSingletonScope(); Kernel.Rebind<IWordCountCache>().ToMethod(ctx => ctx.Kernel.Get<WordCountCache>());
-
When would you use this? It's valuable when use Interface Segregation but have one object implement two interfaces. For example, if you had seperate interfaces for a repository, one read only and one write only:
IUserReadRepository
andIUserWriteRepository
. And both interfaces are implemented byUserRepository
. IfUserRepository
needed to be a Singleton because it did some long running initialization, then it would be necessary to use this technique to make sure a request to either interface returned the same instance:Kernel.Bind<UserRepository>().ToSelf().InSingletonScope(); Kernel.Bind<IUserReadRepository>().ToMethod(ctx => ctx.Kernel.Get<UserRepository>()); Kernel.Bind<IUserWriteRepository>().ToMethod(ctx => ctx.Kernel.Get<UserRepository>());
-
- This can be fixed by adding
-
-
Improve
WordCountingEngineTests
Performance-
You might have noticed that our cross component tests are now running a lot longer -
WordCountingEngine
is having to initialize its cache on every Test execution. -
We'll add a mock IThreadSleeper that doesn't actually sleep so our tests run quickly again.
-
Update
SkyKick.NinjectWorkshop.WordCoutning.Tests.WordCountingEngineTests
:using System; using System.Threading; using System.Threading.Tasks; using Ninject; using NUnit.Framework; using Rhino.Mocks; using Should; using SkyKick.Bcl.Extensions.Reflection; using SkyKick.Bcl.Logging.ConsoleTestLogger; using SkyKick.Bcl.Logging.Infrastructure; using SkyKick.NinjectWorkshop.WordCounting.Http; using SkyKick.NinjectWorkshop.WordCounting.Threading; using SkyKick.NinjectWorkshop.WordCounting.UI; namespace SkyKick.NinjectWorkshop.WordCounting.Tests { /// <summary> /// Tests for <see cref="WordCountingEngineTests"/> /// </summary> [TestFixture] public class WordCountingEngineTests { /// <summary> /// Cross Component test that tests the happy path of /// <see cref="WordCountingEngine"/> counting the correct /// number of words on a web page using mocked /// Web Content /// </summary> [Test] [TestCase( "SkyKick.NinjectWorkshop.WordCounting.Tests.SampleFiles.TwoWordsHtml.txt", 2)] [TestCase( "SkyKick.NinjectWorkshop.WordCounting.Tests.SampleFiles.WordsWithEntersAndNoSpaces.txt", 3)] public async Task CountsWordsInSampleFilesCorrectly( string embeddedHtmlResourceName, int expectedCount) { // ARRANGE var fakeUrl = "http://testing.com/"; var fakeToken = new CancellationTokenSource().Token; var fakeWebContent = GetType().Assembly.GetEmbeddedResourceAsString(embeddedHtmlResourceName); var kernel = new Startup().BuildKernel(); var mockWebClient = MockRepository.GenerateMock<IWebClient>(); mockWebClient .Stub(x => x.GetHtmlAsync( Arg.Is(fakeUrl), Arg.Is(fakeToken))) .Return(Task.FromResult(fakeWebContent)); var mockThreadSleeper = MockRepository.GenerateMock<IThreadSleeper>(); mockThreadSleeper .Stub(x => x.Sleep(Arg<TimeSpan>.Is.Anything)); kernel.Rebind<IWebClient>().ToConstant(mockWebClient); kernel.Rebind<IThreadSleeper>().ToConstant(mockThreadSleeper); var wordCountingEngine = kernel.Get<WordCountingEngine>(); // ACT var count = await wordCountingEngine.CountWordsOnUrlAsync(fakeUrl, fakeToken); // ASSERT count.ShouldEqual(expectedCount); } } }
- This is another example where we can take advantage of the benefit of having the
IThreadSleeper
wrapper.
- This is another example where we can take advantage of the benefit of having the
-
-
Re-Run
WordCountingEngineTests
and confirm it passes and runs in less than 1 second.
We have just recieved a new requirement: Our application must be able to read and count words from File in addition to a reading and counting from a Web Server.
This will require a bit of a redesign as the initial design was tightly coupled with the idea of reading from Web pages.
-
Create a new Class at the root of
SkyKick.NinjectWorkshop.WordCounting
calledITextSource
:using System.Threading; using System.Threading.Tasks; namespace SkyKick.NinjectWorkshop.WordCounting { /// <summary> /// Interface for any component that can provide /// Text for <see cref="WordCountingEngine"/> to count. /// </summary> public interface ITextSource { /// <summary> /// Identifies a specific instance of a /// <see cref="ITextSource"/>. Used /// for Caching and Logging /// </summary> string TextSourceId {get; } Task<string> GetTextAsync(CancellationToken token); } }
-
To make 'text source' generic, we can't have a named method (like
GetTextFromUrl
) that takes initialization data. We'll need to do all of our initialization in the constructor. -
We'll expose a
TextSourceId
for logging / cache key
-
-
Update
WebTextSource
to implementITextSource
:using System; using System.Net; using System.Threading; using System.Threading.Tasks; using Polly; namespace SkyKick.NinjectWorkshop.WordCounting.Http { /// <summary> /// Don't build / bind directly, use <see cref="IWebTextSourceFactory"/> /// </summary> internal class WebTextSource : ITextSource { private readonly IWebClient _webClient; private readonly WebTextSourceOptions _options; private readonly string _url; public WebTextSource(IWebClient webClient, WebTextSourceOptions options, string url) { _webClient = webClient; _options = options; _url = url; } public string TextSourceId => _url; public async Task<string> GetTextAsync(CancellationToken token) { var policy = Polly.Policy .Handle<WebException>(webException => (webException.Response as HttpWebResponse)?.StatusCode == HttpStatusCode.InternalServerError) .Or<Exception>(ex => !(ex is WebException)) .WaitAndRetryAsync(_options.RetryTimes); var html = await policy.ExecuteAsync( _ => _webClient.GetHtmlAsync(_url, token), token); return new CsQuery.CQ(html).Text(); } } }
- Note how we now need to take
url
in the constructor- Because of this we now have a parameter that we need to pass in to the constructor that does not support DI. Time to use a Factory.
- Note how we now need to take
-
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.Http
calledWebTextSourceFactory
:namespace SkyKick.NinjectWorkshop.WordCounting.Http { public interface IWebTextSourceFactory { ITextSource CreateWebTextSource(string url); } internal class WebTextSourceFactory : IWebTextSourceFactory { private readonly IWebClient _webClient; private readonly WebTextSourceOptions _options; public WebTextSourceFactory(IWebClient webClient, WebTextSourceOptions options) { _webClient = webClient; _options = options; } public ITextSource CreateWebTextSource(string url) { return new WebTextSource(_webClient, _options, url); } } }
-
We'll need to create an interface to define the factory signature so the factory can be consumed by other classes.
-
Implementation will use constructor injection to pull in all of the dependencies that
WebTextSource
needs, and then will complement that with the non-injectable parameters it needs:url
. -
This allows us to still use DI everywhere, but still support initialization input that will be provided by run time data; in this case user input
-
It might feel wierd to see the
new
keyword again, but this is perfectly ok.
-
-
Update
WordCountingEngine
to useITextSource
:using System.Threading; using System.Threading.Tasks; using SkyKick.Bcl.Logging; using SkyKick.NinjectWorkshop.WordCounting.Cache; using SkyKick.NinjectWorkshop.WordCounting.Http; namespace SkyKick.NinjectWorkshop.WordCounting { public interface IWordCountingEngine { Task<int> CountWordsFromTextSourceAsync(ITextSource source, CancellationToken token); } internal class WordCountingEngine : IWordCountingEngine { private readonly IWordCountingAlgorithm _wordCountingAlgorithm; private readonly IWordCountCache _wordCountCache; private readonly ILogger _logger; public WordCountingEngine( IWordCountingAlgorithm wordCountingAlgorithm, ILogger logger, IWordCountCache wordCountCache) { _wordCountingAlgorithm = wordCountingAlgorithm; _logger = logger; _wordCountCache = wordCountCache; } public async Task<int> CountWordsFromTextSourceAsync( ITextSource source, CancellationToken token) { _logger.Debug($"Counting Words on [{source.TextSourceId}]"); int wordCount; if (_wordCountCache.TryGet(source.TextSourceId, out wordCount)) return wordCount; var text = await source.GetTextAsync(token); wordCount = _wordCountingAlgorithm.CountWordsInString(text); _wordCountCache.Add(source.TextSourceId, wordCount); return wordCount; } } }
- We've replaced the url parameter to now use a
ITextSource
- We've replaced the url parameter to now use a
-
Create a new Folder in
SkyKick.NinjectWorkshop.WordCounting
calledFile
-
Add a NuGet reference to
SkyKick.Bcl.Extensions
inSkyKick.NinjectWorkshop.WordCounting
from the SkyKick nuget feed -
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.File
calledFileTextSource
:using System.Threading; using System.Threading.Tasks; using SkyKick.Bcl.Extensions.File; namespace SkyKick.NinjectWorkshop.WordCounting.File { public interface IFileTextSource : ITextSource{} /// <summary> /// Don't build / bind directly, use <see cref="IFileTextSourceFactory"/> /// </summary> internal class FileTextSource : IFileTextSource { private readonly IFile _file; private readonly string _path; public FileTextSource(IFile file, string path) { _file = file; _path = path; } public string TextSourceId => _path; public Task<string> GetTextAsync(CancellationToken token) { return Task.FromResult(_file.RealAllText(_path)); } } }
- We'll use
SkyKick.Bcl.Extensions.File.IFile
to pull in an existing abstraction around the File System.
- We'll use
-
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.File
calledIFileTextSourceFactory
:namespace SkyKick.NinjectWorkshop.WordCounting.File { public interface IFileTextSourceFactory { IFileTextSource CreateFileTextSource(string path); } }
- For
IFileTextSourceFactory
we'll use a plugin to avoid having to write the boiler plate factory code we wrote inWebTextSourceFactory
that pulled in the dependencies and passed them to theWebTextSouce
constructor.- This plugin will use a number of conventions. Method must start with
Create
and we must create aIFileTextSource
to help the Factory
- This plugin will use a number of conventions. Method must start with
- For
-
Add a Nuget reference to
Ninject.Extensions.Factory 3.2.1.0
inSkyKick.NinjectWorkshop.WordCounting
-
Update
SkyKick.NinjectWorkshop.WordCounting.NinjectModule
with the specail binding forIFileTextSourceFactory
:using Ninject.Extensions.Conventions; using Ninject.Extensions.Factory; using SkyKick.NinjectWorkshop.WordCounting.Cache; using SkyKick.NinjectWorkshop.WordCounting.File; using SkyKick.NinjectWorkshop.WordCounting.Http; namespace SkyKick.NinjectWorkshop.WordCounting { public class NinjectModule : Ninject.Modules.NinjectModule { public override void Load() { Kernel.Bind(x => x.FromThisAssembly() .IncludingNonePublicTypes() .SelectAllClasses() .BindDefaultInterface()); Kernel.Bind<IWebClient>().To<WebClientWrapper>(); Kernel.Rebind<IWordCountCache>().To<WordCountCache>().InSingletonScope(); Kernel.Bind<IFileTextSourceFactory>().ToFactory(); } } }
-
We're going to need to modify
SkyKick.NinjectWorkshop.WordCounting.UI.Repl
and create helper classes for it to use, but before we do let's create a new namespace for repl.-
Create a Folder in
SkyKick.NinjectWorkshop.WordCounting.UI
calledRepl
-
Move the
Repl
class file into theRepl
folder. -
Update the namespace in the
Repl
class toSkyKick.NinjectWorkshop.WordCounting.UI.Repl
-
-
Add a new Class to
SkyKick.NinjectWorkshop.WordCounting.UI.Repl
calledTextSources
:namespace SkyKick.NinjectWorkshop.WordCounting.UI.Repl { public enum TextSources { File = 1, Web = 2 } }
-
Add a new Class to
SkyKick.NinjectWorkshop.WordCounting.UI.Repl
calledReplTextSourceBuilder
:using System; using SkyKick.NinjectWorkshop.WordCounting.File; using SkyKick.NinjectWorkshop.WordCounting.Http; namespace SkyKick.NinjectWorkshop.WordCounting.UI.Repl { public interface IReplTextSourceBuilder { ITextSource PromptUserForInputAndBuildTextSource(TextSources textSource); } internal class ReplTextSourceBuilder : IReplTextSourceBuilder { private readonly IFileTextSourceFactory _fileTextSourceFactory; private readonly IWebTextSourceFactory _webTextSourceFactory; public ReplTextSourceBuilder( IFileTextSourceFactory fileTextSourceFactory, IWebTextSourceFactory webTextSourceFactory) { _fileTextSourceFactory = fileTextSourceFactory; _webTextSourceFactory = webTextSourceFactory; } public ITextSource PromptUserForInputAndBuildTextSource(TextSources textSource) { switch (textSource) { case TextSources.File: Console.Write("Enter Path: "); var path = Console.ReadLine(); return _fileTextSourceFactory.CreateFileTextSource(path); case TextSources.Web: Console.Write("Enter Url: "); var url = Console.ReadLine(); return _webTextSourceFactory.CreateWebTextSource(url); default: throw new NotImplementedException( $"{Enum.GetName(typeof(TextSources), textSource)} is Not Supported"); } } } }
- This will drive accepting user input and using the correct Text Source Factory to create a
ITextSource
. - We inject both factories and then decide, based on user input, which one to use to build the ITextSource we want to build.
- This will drive accepting user input and using the correct Text Source Factory to create a
-
Update
SkyKick.NinjectWorkshop.WordCounting.UI.Repl.Repl
to useReplTextSourceBuilder
:using System; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace SkyKick.NinjectWorkshop.WordCounting.UI.Repl { internal class Repl { private readonly IReplTextSourceBuilder _replTextSourceBuilder; private readonly IWordCountingEngine _wordCountingEngine; public Repl(IReplTextSourceBuilder replTextSourceBuilder, IWordCountingEngine wordCountingEngine) { _replTextSourceBuilder = replTextSourceBuilder; _wordCountingEngine = wordCountingEngine; } public async Task RunAsync(CancellationToken token) { Console.WriteLine("Available Text Sources: "); Console.WriteLine( string.Join( "\r\n", Enum.GetValues(typeof(TextSources)) .Cast<object>() .Select(v => $"Enter [{(int)v}] for {Enum.GetName(typeof(TextSources), v)}") .ToArray())); var textSourceSelection = (TextSources)Enum.Parse(typeof(TextSources), Console.ReadLine()); var textSource = _replTextSourceBuilder.PromptUserForInputAndBuildTextSource(textSourceSelection); var count = await _wordCountingEngine.CountWordsFromTextSourceAsync(textSource, token); Console.WriteLine($"Number of words on [{textSource.TextSourceId}]: {count}"); Console.WriteLine(); } } }
-
Add a NuGet reference to
Ninject.Extensions.Conventions 3.2.0.0
toSkyKick.NinjectWorkshop.WordCounting.UI
-
We're now injecting a
IReplTextSourceBuilder
intoRepl
. We don't have a Ninject Module forSkyKick.NinjectWorkshop.WordCounting.UI
soRepl
will no longer resolve correclty.-
Add a new Class to
SkyKick.NinjectWorkshop.WordCounting.UI
calledNinjectModule
:using Ninject.Extensions.Conventions; namespace SkyKick.NinjectWorkshop.WordCounting.UI { public class NinjectModule : Ninject.Modules.NinjectModule { public override void Load() { Kernel.Bind(x => x.FromThisAssembly() .IncludingNonePublicTypes() .SelectAllClasses() .BindDefaultInterface()); } } }
-
-
Update
Startup
to use the newNinjectModule
:using Ninject; namespace SkyKick.NinjectWorkshop.WordCounting.UI { public class Startup { public IKernel BuildKernel() { return new StandardKernel( new SkyKick.Bcl.Logging.ConsoleTestLogger.NinjectModule(), new SkyKick.NinjectWorkshop.WordCounting.NinjectModule(), new SkyKick.NinjectWorkshop.WordCounting.UI.NinjectModule()); } } }
-
We've refactored a few classes that have impacted our Tests. We'll need to update them.
-
This shows that having Tests does incur costs - it requires effort to keep them up to date. Therefor it's important that the Tests deliver value. Blindly adding a Unit Test becase you can isn't necessarily the best approach. This is one of the reasons the
WordCountingEngine
Cross Component test is valuable - it tests a number of classes together so we get more test coverage for less maintenance cost. -
Update
WordCountingEngineTests
:using System; using System.Threading; using System.Threading.Tasks; using Ninject; using NUnit.Framework; using Rhino.Mocks; using Should; using SkyKick.Bcl.Extensions.Reflection; using SkyKick.Bcl.Logging.ConsoleTestLogger; using SkyKick.Bcl.Logging.Infrastructure; using SkyKick.NinjectWorkshop.WordCounting.Http; using SkyKick.NinjectWorkshop.WordCounting.Threading; using SkyKick.NinjectWorkshop.WordCounting.UI; namespace SkyKick.NinjectWorkshop.WordCounting.Tests { /// <summary> /// Tests for <see cref="WordCountingEngineTests"/> /// </summary> [TestFixture] public class WordCountingEngineTests { /// <summary> /// Cross Component test that tests the happy path of /// <see cref="WordCountingEngine"/> counting the correct /// number of words on a web page using mocked /// Web Content /// </summary> [Test] [TestCase( "SkyKick.NinjectWorkshop.WordCounting.Tests.SampleFiles.TwoWordsHtml.txt", 2)] [TestCase( "SkyKick.NinjectWorkshop.WordCounting.Tests.SampleFiles.WordsWithEntersAndNoSpaces.txt", 3)] public async Task CountsWordsInSampleFilesCorrectly( string embeddedHtmlResourceName, int expectedCount) { // ARRANGE var fakeUrl = "http://testing.com/"; var fakeToken = new CancellationTokenSource().Token; var fakeWebContent = GetType().Assembly.GetEmbeddedResourceAsString(embeddedHtmlResourceName); var kernel = new Startup().BuildKernel(); var mockWebClient = MockRepository.GenerateMock<IWebClient>(); mockWebClient .Stub(x => x.GetHtmlAsync( Arg.Is(fakeUrl), Arg.Is(fakeToken))) .Return(Task.FromResult(fakeWebContent)); var mockThreadSleeper = MockRepository.GenerateMock<IThreadSleeper>(); mockThreadSleeper .Stub(x => x.Sleep(Arg<TimeSpan>.Is.Anything)); kernel.Rebind<IWebClient>().ToConstant(mockWebClient); kernel.Rebind<IThreadSleeper>().ToConstant(mockThreadSleeper); var webTextSource = kernel.Get<IWebTextSourceFactory>().CreateWebTextSource(fakeUrl); var wordCountingEngine = kernel.Get<WordCountingEngine>(); // ACT var count = await wordCountingEngine.CountWordsFromTextSourceAsync(webTextSource, fakeToken); // ASSERT count.ShouldEqual(expectedCount); } } }
-
Update
WebTextSourceTests
:public async Task InvokesRetryPolicyOnErrors(Exception webClientException, bool expectRetry) { // ARRANGE var fakeWebTextSourceOptions = new WebTextSourceOptions { RetryTimes = new[] { TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(0) } }; var fakeUrl = "http://testing.com"; var fakeToken = new CancellationTokenSource().Token; var mockWebClient = MockRepository.GenerateStrictMock<IWebClient>(); mockWebClient .Expect(x => x.GetHtmlAsync(Arg.Is(fakeUrl), Arg.Is(fakeToken))) .Throw(webClientException) .Repeat.Times( // 1 for initial call and then any retries 1 + (expectRetry ? fakeWebTextSourceOptions.RetryTimes.Length : 0)); var webTextSource = new WebTextSource(mockWebClient, fakeWebTextSourceOptions, fakeUrl); // ACT try { await webTextSource.GetTextAsync(fakeToken); Assert.Fail("Expected an exception to be thrown but was not."); } catch (Exception e) { // ASSERT e.ShouldEqual(webClientException); mockWebClient.VerifyAllExpectations(); } }
-
-
Run all Tests and verify they pass
Lets add some arbitrary complexity to our application to simulate a real word business demand. Then we'll see how to leverage Behavior Driven Development (BDD)'s style of testing to easily write some powerful and wide reaching tests.
For this example let's say we've gotten the following requirements:
- If the Word Count is greater than 1000 words then we'll send an email saying "More than 1000 words"
- If the Word Count is less than 1000 words then we'll send an email saying "Less than 1000 words"
- If there is an error counting words, then no email is sent.
-
Create a new Folder in
SkyKick.NinjectWorkshop.WordCounting
calledEmail
-
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.Email
calledEmailClient
:using System.Threading; using System.Threading.Tasks; using SkyKick.Bcl.Logging; namespace SkyKick.NinjectWorkshop.WordCounting.Email { public interface IEmailClient { Task SendEmailAsync( string to, string from, string body, CancellationToken token); } internal class EmailClient : IEmailClient { private readonly ILogger _logger; public EmailClient(ILogger logger) { _logger = logger; } public Task SendEmailAsync(string to, string from, string body, CancellationToken token) { _logger.Info( $"Sending Email To [{to}] From [{from}]: \r\n" + body); return Task.FromResult(true); } } }
-
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting
calledWordCountingWorkflow
:using System; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using SkyKick.Bcl.Logging; using SkyKick.NinjectWorkshop.WordCounting.Email; namespace SkyKick.NinjectWorkshop.WordCounting { public interface IWordCountingWorkflow { /// <summary> /// Counts Words in <paramref name="source"/>, and sends specific /// emails based on the results. /// /// Still returns the total word count. /// </summary> Task<int> RunWordCountWorkflowAsync(ITextSource source, CancellationToken token); } internal class WordCountingWorkflow : IWordCountingWorkflow { private readonly IWordCountingEngine _wordCountingEngine; private readonly IEmailClient _emailClient; private readonly ILogger _logger; public WordCountingWorkflow( IWordCountingEngine wordCountingEngine, IEmailClient emailClient, ILogger logger) { _wordCountingEngine = wordCountingEngine; _emailClient = emailClient; _logger = logger; } public async Task<int> RunWordCountWorkflowAsync(ITextSource source, CancellationToken token) { var stopWatch = Stopwatch.StartNew(); int count = 0; try { count = await _wordCountingEngine.CountWordsFromTextSourceAsync(source, token); if (count < 1000) await _emailClient .SendEmailAsync( "[email protected]", "[email protected]", "Less than 1000", token); else await _emailClient .SendEmailAsync( "[email protected]", "[email protected]", "More than 1000", token); } catch (Exception e) { _logger.Error($"Exception in Workflow: {e.Message}", e); } _logger.Debug($"Completed Count Workflow for [{source.TextSourceId}] in [{stopWatch.Elapsed}]"); return count; } } }
-
Update
Repl
to useWordCountingWorkflow
:using System; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace SkyKick.NinjectWorkshop.WordCounting.UI.Repl { internal class Repl { private readonly IReplTextSourceBuilder _replTextSourceBuilder; private readonly IWordCountingWorkflow _wordCountingWorkflow; public Repl(IReplTextSourceBuilder replTextSourceBuilder, IWordCountingWorkflow wordCountingWorkflow) { _replTextSourceBuilder = replTextSourceBuilder; _wordCountingWorkflow = wordCountingWorkflow; } public async Task RunAsync(CancellationToken token) { Console.WriteLine("Available Text Sources: "); Console.WriteLine( string.Join( "\r\n", Enum.GetValues(typeof(TextSources)) .Cast<object>() .Select(v => $"Enter [{(int)v}] for {Enum.GetName(typeof(TextSources), v)}") .ToArray())); var textSourceSelection = (TextSources)Enum.Parse(typeof(TextSources), Console.ReadLine()); var textSource = _replTextSourceBuilder.PromptUserForInputAndBuildTextSource(textSourceSelection); var count = await _wordCountingWorkflow.RunWordCountWorkflowAsync(textSource, token); Console.WriteLine($"Number of words on [{textSource.TextSourceId}]: {count}"); Console.WriteLine(); } } }
-
Verify all Tests in the Solution pass.
-
Create a new Folder in
SkyKick.NinjectWorkshop.WordCounting.Tests
calledHelpers
-
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.Tests.Helpers
calledWebExceptionHelper
:using System; using System.Net; using SkyKick.Bcl.Extensions.Reflection; namespace SkyKick.NinjectWorkshop.WordCounting.Tests.Helpers { public static class WebExceptionHelper { /// <summary> /// Have to use reflection to build <see cref="WebException"/> /// because Microsoft doesn't provide public constructors / setters /// <para /> /// This leverages tools from <see cref="SkyKick.Bcl.Extensions.Reflection"/> /// to make it a bit easier. /// </summary> public static WebException CreateWebExceptionWithStatusCode(HttpStatusCode status) { var httpWebResponse = (HttpWebResponse) Activator.CreateInstance( typeof(HttpWebResponse), false); typeof(HttpWebResponse) .CreateFieldAccessor<HttpStatusCode>("m_StatusCode") .Set(httpWebResponse, status); var webException = new WebException(""); typeof(WebException) .CreateFieldAccessor<WebResponse>("m_Response") .Set(webException, httpWebResponse); return webException; } } }
- Optional:
CreateWebExceptionWithStatusCode
is a copy of the private method that was inWebTextSourceTests
. Remove the private method fromWebTextSourceTests
and update the Tests in that file to useWebExceptionHelper
.
- Optional:
-
Add a NuGet reference to
Ninject.MockingKernel.RhinoMocks 3.2.2.0
toSkyKick.NinjectWorkshop.WordCounting.Tests
-
Update the NuGet reference to
Ninject.MockingKernel
to3.2.2.0
inSkyKick.NinjectWorkshop.WordCounting.Tests
Ninject.MockingKernel.RhinoMocks
automatically installsNinject.MockingKernel
, however it installs an incompatible version. If you don't upgrade you'll get Binding Exceptions when trying to use the Mocking Kernel.
-
Add a new Class in
SkyKick.NinjectWorkshop.WordCounting.Tests
calledWordCountingWorkflowTests
:using System.Threading; using System.Threading.Tasks; using Ninject; using Ninject.MockingKernel.RhinoMock; using NUnit.Framework; using Rhino.Mocks; using SkyKick.NinjectWorkshop.WordCounting.Email; namespace SkyKick.NinjectWorkshop.WordCounting.Tests { /// <summary> /// Tests <see cref="WordCountingWorkflow"/> /// </summary> [TestFixture] public class WordCountingWorkflowTests { /// <summary> /// Verifies that <see cref="WordCountingWorkflow"/> sends the /// correct email based on the result of /// <see cref="IWordCountingEngine.CountWordsFromTextSourceAsync"/>. /// /// NOTE: If this test fails with a Null Reference Exception, that likely /// means the wrong email was sent, the Mock Behavior didn't match on /// <see cref="IEmailClient"/> so <see cref="WordCountingWorkflow"/> ended /// up awaiting a null Task /// </summary> [Test] [TestCase(500, "Less than 1000")] [TestCase(999, "Less than 1000")] [TestCase(1000, "More than 1000")] [TestCase(5000, "More than 1000")] public async Task SendsCorrectEmailBasedOnWordCount(int wordCount, string expectedEmailBody) { // ARRANGE var fakeTextSource = MockRepository.GenerateMock<ITextSource>(); var fakeToken = new CancellationTokenSource().Token; var mockingKernel = new RhinoMocksMockingKernel(); mockingKernel .Get<IWordCountingEngine>() .Expect(x => x.CountWordsFromTextSourceAsync( Arg.Is(fakeTextSource), Arg.Is(fakeToken))) .Return(Task.FromResult(wordCount)) .Repeat.Once(); mockingKernel .Get<IEmailClient>() .Expect(x => x.SendEmailAsync( to: Arg<string>.Is.Anything, from: Arg<string>.Is.Anything, body: Arg.Is(expectedEmailBody), token: Arg.Is(fakeToken))) .Return(Task.FromResult(true)) .Repeat.Once(); var wordCountWorkflow = mockingKernel.Get<WordCountingWorkflow>(); // ACT await wordCountWorkflow.RunWordCountWorkflowAsync(fakeTextSource, fakeToken); // ASSERT mockingKernel .Get<IEmailClient>() .VerifyAllExpectations(); } } }
-
WordCountingWorkflow
has a lot of dependencies, so this Test uses a Mocking Kernel to make it easier to deal with them. Mocking Kernel will automatically mock any dependency that is requested via aGet()
call. We can also add real bindings to it if we wanted, but that's not necessary here.-
Use the
mockingKernel.Get<>().Stub()
{.language-csharp} syntax to directly add a Stub to a mock. -
Notice how we haven't added any Behavior for an
ILogger
even thoughWordCountWorkflow
takes one as a dependency. The Mocking Kernel will automatically generate a mock for us and give itwordCountWorkflow
. Any because all of the logging calls return void, the default mock provided workds just fine here.
-
-
Pro Tip - I like to add hints in test descriptions on how to interpret and fix a test if it shows failure conditions. In this case, if the wrong email is sent, a null refernece exception will be thrown, so I document this in the test comments.
-
The SendsCorrectEmailBasedOnWordCount
we just wrote is a good unit test, it tests the primary function of WordCountingWorkflow
as an isolated unit. However, since we have followed the Single Responsibility principle, WordCountingWorkflow
primary work is done in if (count < 1000)
{.language-csharp} and our test is of limited value. It would be more valuable if, instead, we could test the larger business value that is being provided.
Behavior Driven Development (BDD) provides a framework for doing this. It estabilishes a set of keywords that can be used to describe an entire business scenario and has the added bonus of doing so in such a way that we create a very human readable set of documentation on what our system does that can be easily understood by multiple stake holders including developers, QA, Product Managers, and other Business Users.
The BDD keywords are Given, When, Then:
- Given - Describe the setup for a Scerario
- When - Describe the execution of a Scenario
- Then - Describe the expecetatios following execution of a Scenario
This common sytnax and collaboriation between stake holders is especially powerful when combined with the Agile process in a technique known as Acceptance Test Drvien Development. ATDD codifies a story using BDD's Given/When/Then keywords before development begins. By generating compilable and verifiable tests we can both prove a Story has been completed by pointing towards a series of passing Tests as well as create a record of all completed Stories as development progresses. Additionally, the practice of generating Scenarios during the planning process can aid in estimation - the more numerous and complex the Scenarios are necessary to describe a Story, the larger its likely to be.
Lets take a look at an example of some BDD tests with a few Scenarios similar to:
GIVEN a Url that points to a web site with 3000 words
WHEN the word counting workflow is run
THEN the more than the "more than 1000 words" email is sent
and THEN the website is queried only once
and THEN no exception is logged
and THEN no exception is thrown
-
Add the Sample Files we'll need for the Scenario
- Create a new File in
SkyKick.NinjectWorkshop.WordCounting.Tests\SampleFiles
called3000Words.txt
and copy the contents from: 3000Words.txt - Create a new File in
SkyKick.NinjectWorkshop.WordCounting.Tests\SampleFiles
called500Words.txt
and copy the contents from: 500Words.txt
- Create a new File in
-
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.Tests
calledWordCountingWorkflowScenarioTests
:using System.Threading; using System.Threading.Tasks; using Ninject; using NUnit.Framework; using Rhino.Mocks; using SkyKick.Bcl.Extensions.Reflection; using SkyKick.Bcl.Logging; using SkyKick.NinjectWorkshop.WordCounting.Email; using SkyKick.NinjectWorkshop.WordCounting.Http; using SkyKick.NinjectWorkshop.WordCounting.Threading; using SkyKick.NinjectWorkshop.WordCounting.UI; using System; using System.Net; using SkyKick.NinjectWorkshop.WordCounting.Tests.Helpers; namespace SkyKick.NinjectWorkshop.WordCounting.Tests { public class WordCountingWorkflowScenarioTests { private class TestHarness { private const string _fakeUrl = "http://test.com"; private readonly WebTextSource _webTextSource; private readonly IWebClient _mockWebClient; private readonly IEmailClient _mockEmailClient; private readonly ILogger _mockLogger; private readonly WordCountingWorkflow _wordCountingWorkflow; public TestHarness(WebTextSourceOptions options = null) { var kernel = new Startup().BuildKernel(); _mockWebClient = MockRepository.GenerateMock<IWebClient>(); kernel.Rebind<IWebClient>().ToConstant(_mockWebClient); _mockEmailClient = MockRepository.GenerateMock<IEmailClient>(); _mockEmailClient .Stub(x => x.SendEmailAsync( to: Arg<string>.Is.Anything, from: Arg<string>.Is.Anything, body: Arg<string>.Is.Anything, token: Arg<CancellationToken>.Is.Anything)) .Return(Task.FromResult(true)); kernel.Rebind<IEmailClient>().ToConstant(_mockEmailClient); _mockLogger = MockRepository.GenerateMock<ILogger>(); _mockLogger .Stub(x => x.Debug(Arg<string>.Is.Anything, Arg<LoggingContext>.Is.Anything)) // capture Debug Messages and write to Console so we can see messages // in test window. .Do(new Action<string, LoggingContext>((msg, ctx) => Console.WriteLine(msg))); kernel.Rebind<ILogger>().ToConstant(_mockLogger); // Disable the Cache Initializer's Thread Sleeper kernel.Rebind<IThreadSleeper>().ToConstant(MockRepository.GenerateMock<IThreadSleeper>()); _wordCountingWorkflow = kernel.Get<WordCountingWorkflow>(); _webTextSource = new WebTextSource( _mockWebClient, options ?? kernel.Get<WebTextSourceOptions>(), _fakeUrl); } #region GIVEN Helpers public TestHarness WebSiteHasHtml(string html) { _mockWebClient .Stub(x => x.GetHtmlAsync( Arg.Is(_fakeUrl), Arg<CancellationToken>.Is.Anything)) .Return(Task.FromResult(html)); return this; } public TestHarness WebSiteThrowsWebException(HttpStatusCode statusCode) { _mockWebClient .Stub(x => x.GetHtmlAsync( Arg.Is(_fakeUrl), Arg<CancellationToken>.Is.Anything)) .Throw(WebExceptionHelper.CreateWebExceptionWithStatusCode(statusCode)); return this; } #endregion #region WHEN Helpers public TestHarness RunWordCountWorkflow() { _wordCountingWorkflow .RunWordCountWorkflowAsync(_webTextSource, CancellationToken.None) .Wait(); return this; } #endregion #region THEN Helpers public TestHarness VerifyWebClientWasCalled(int numberOfTimes) { _mockWebClient .AssertWasCalled(x => x.GetHtmlAsync( Arg.Is(_fakeUrl), Arg<CancellationToken>.Is.Anything), options => options.Repeat.Times(numberOfTimes)); return this; } public TestHarness VerifyTheOnlyEmailSentHad(string body, int numberOfTimes) { // test the expected email was sent the correct number of times _mockEmailClient .AssertWasCalled(x => x.SendEmailAsync( to: Arg<string>.Is.Anything, from: Arg<string>.Is.Anything, body: Arg.Is(body), token: Arg<CancellationToken>.Is.Anything), options => options.Repeat.Times(numberOfTimes)); // test no other emails were sent _mockEmailClient .AssertWasNotCalled(x => x.SendEmailAsync( to: Arg<string>.Is.Anything, from: Arg<string>.Is.Anything, body: Arg<string>.Matches(b => !string.Equals(b, body)), token: Arg<CancellationToken>.Is.Anything)); return this; } public TestHarness VerifyThatNoEmailWasSent() { // can just reuse VerifyTheOnlyEmailSentHad, but pass it 0 return VerifyTheOnlyEmailSentHad(body: "no body", numberOfTimes: 0); } public TestHarness VerifyExceptionLoggedAsExpected(bool shouldBeLogged) { _mockLogger .AssertWasCalled(x => x.Error( Arg<string>.Matches(msg => msg.Contains("Exception")), Arg<Exception>.Is.Anything, Arg<LoggingContext>.Is.Anything), options => options.Repeat.Times(shouldBeLogged ? 1 : 0)); return this; } #endregion } } }
-
The
TestHarness
will be used by all of the Scenarios we'll use in the next steps. The idea is it will allow our Scenarios to be very clean and concise. -
Test Harness sets up Mocks and then exposes helper methods for our BDD tests to perform setup and validation. This is very similar to a Cross Componenet test, the idea here is to test as much of the stack as possible, so we'll only mock out the
WebClient
andEmailClient
, so we're fully runningWordCountingWorkflow
,WordCountingEngine
,WordCountingAlgorithm
,WordCountCache
, andWebTextSource
-
Note how
VerifyTheOnlyEmailSentHad
does a double verification, first verifying that the correct email was sent the correct number of times and then verifying that no other email was sent. This technique is important for making sure that Tests are robust enough to catch a case when the wrong input is pased to a method. -
Note how all of the Given/When/Then helpers return
TestHarness
. This is called Fluent Syntax and is not strictly necessary. It will allows us to do method chaining and make the consuming code a bit more readable.
-
-
Add the Scenarios to
WordCountingWorkflowScenarioTests
:public class WordCountingWorkflowScenarioTests { //private class TestHarness { .. } [TestFixture] [Category("WordCountingWorkflowScenarios")] public class GivenAUrlThatPointsToAWebSiteWith3000Words { private readonly TestHarness _testHarness; public GivenAUrlThatPointsToAWebSiteWith3000Words() { _testHarness = new TestHarness(); _testHarness .WebSiteHasHtml( GetType().Assembly.GetEmbeddedResourceAsString( "SkyKick.NinjectWorkshop.WordCounting.Tests.SampleFiles.3000Words.txt")); } [TestFixtureSetUp] public void WhenTheWordCountingWorkflowIsRun() { _testHarness.RunWordCountWorkflow(); } [Test] public void ThenTheWebSiteIsQueriedOnlyOnce() { _testHarness.VerifyWebClientWasCalled(numberOfTimes: 1); } [Test] public void ThenTheMoreThan1000WordsEmailIsSent() { _testHarness.VerifyTheOnlyEmailSentHad(body: "More than 1000", numberOfTimes: 1); } [Test] public void ThenNoExceptionIsLogged() { _testHarness.VerifyExceptionLoggedAsExpected(shouldBeLogged: false); } [Test] public void ThenNoExceptionIsThrown() { // if an exception was thrown, we wouldn't get here so nothing to test } } }
-
Notice how concise and readable the code is and yet how much code coverage we get! The tough work of setting up the test is done in the
TestHarness
so that the Scenario can be quite clean. -
Because the Scenario is a class marked with its own
[TestFixture]
{.language-csharp} we can have multiple[Test]
methods. This makes it very easy to adhear to the BDD Then syntax and makes it so that each[Test]
method is focused on proving a single post condition. -
I execute the Given step in the classes constructor to setup the
TestHarness
and initialize it with theWebSiteHasHtml
. -
The When step is executed in the
WhenTheWordCountingWorkflowIsRun
method, which clearly indicates the action that is being performed. Using the[TestFixtureSetUp]
attribute ensures that the method is only executed once, even if we're executing multiple[Test]
methods.
-
-
Let's add another Scenario to
WordCountingWorkflowScenarioTests
to cover the event that the web site has only 500 words:public class WordCountingWorkflowScenarioTests { //private class TestHarness { .. } [TestFixture] [Category("WordCountingWorkflowScenarios")] public class GivenAUrlThatPointsToAWebSiteWith500Words { private readonly TestHarness _testHarness; public GivenAUrlThatPointsToAWebSiteWith500Words() { _testHarness = new TestHarness(); _testHarness .WebSiteHasHtml( GetType().Assembly.GetEmbeddedResourceAsString( "SkyKick.NinjectWorkshop.WordCounting.Tests.SampleFiles.500Words.txt")); } [TestFixtureSetUp] public void WhenTheWordCountingWorkflowIsRun() { _testHarness.RunWordCountWorkflow(); } [Test] public void ThenTheMoreThan1000WordsEmailIsSent() { _testHarness.VerifyTheOnlyEmailSentHad(body: "Less than 1000", numberOfTimes: 1); } [Test] public void ThenNoExceptionIsLogged() { _testHarness.VerifyExceptionLoggedAsExpected(shouldBeLogged: false); } [Test] public void ThenNoExceptionIsThrown() { // if an exception was thrown, we wouldn't get here so nothing to test } }
- This should highlight that, once the
TestHarness
is in place, its incredibly easy to add new Scenarios!
- This should highlight that, once the
-
We've shown "happy path" Scenarios. But we can also capture failure Scenarios. Add a new child class to
WordCountingWorkflowScenarioTests
:public class WordCountingWorkflowScenarioTests { //private class TestHarness { .. } [TestFixture] [Category("WordCountingWorkflowScenarios")] public class GivenAUrlThatPointsToAWebSiteThatDoesNotExist { private readonly TestHarness _testHarness; public GivenAUrlThatPointsToAWebSiteThatDoesNotExist() { _testHarness = new TestHarness(); _testHarness.WebSiteThrowsWebException(HttpStatusCode.NotFound); } [TestFixtureSetUp] public void WhenTheWordCountingWorkflowIsRun() { _testHarness.RunWordCountWorkflow(); } [Test] public void ThenTheWebSiteIsQueriedOnlyOnce() { _testHarness.VerifyWebClientWasCalled(numberOfTimes: 1); } [Test] public void ThenNoEmailIsSent() { _testHarness.VerifyThatNoEmailWasSent(); } [Test] public void ThenAnExceptionIsLogged() { _testHarness.VerifyExceptionLoggedAsExpected(shouldBeLogged: true); } [Test] public void ThenNoExceptionIsThrown() { // if an exception was thrown, we wouldn't get here so nothing to test } } }
-
And finally we'll add one more Scenario that captures our retry logic - when the web server returns a 500 error. Add another child class to
WordCountingWorkflowScenarioTests
:public class WordCountingWorkflowScenarioTests { [TestFixture] [Category("WordCountingWorkflowScenarios")] public class GivenAUrlThatPointsToAWebSiteThatThrowsAnInternalServerError { private readonly TestHarness _testHarness; private readonly WebTextSourceOptions _webTextSourceOptions; public GivenAUrlThatPointsToAWebSiteThatThrowsAnInternalServerError() { _webTextSourceOptions = new WebTextSourceOptions { RetryTimes = new[] { TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(0) } }; _testHarness = new TestHarness(_webTextSourceOptions); _testHarness.WebSiteThrowsWebException(HttpStatusCode.InternalServerError); } [TestFixtureSetUp] public void WhenTheWordCountingWorkflowIsRun() { _testHarness.RunWordCountWorkflow(); } [Test] public void ThenTheWebSiteIsQueriedMultipleTimes() { _testHarness.VerifyWebClientWasCalled( numberOfTimes: _webTextSourceOptions.RetryTimes.Length + 1); } [Test] public void ThenNoEmailIsSent() { _testHarness.VerifyThatNoEmailWasSent(); } [Test] public void ThenAnExceptionIsLogged() { _testHarness.VerifyExceptionLoggedAsExpected(shouldBeLogged: true); } [Test] public void ThenNoExceptionIsThrown() { // if an exception was thrown, we wouldn't get here so nothing to test } } }
- These failure Scenarios show how easy it is to add not just happy path Scenarios, but also Scenarios that cover complex retry logic!