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
V1Folder, 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
GetRequest 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
V2folder calledSkyKick.NinjectWorkshop.WordCounting. This project will store all of the logic of the Word Counting application.- Add a NuGet reference to
SkyKick.Bcl.Loggingfrom the SkyKick nuget feed. This package provides theILoggerinterface and has nice support for DI and Testing.
- Add a NuGet reference to
-
Create a new Console Application project in the
V2folder 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.Loggingfrom the SkyKick nuget feed
-
-
Create a new Class Library project in the
V2folder calledSkyKick.NinjectWorkshop.WordCounting.Tests. This project will contain Tests for bothSkyKick.NinjectWorkshop.WordCountingandSkyKcik.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.WordCountingcalledWordCountingAlgorithm. -
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.WordCountingcalledHttp. -
Create a new Class file called
WebClientWrapperinHttp: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.4toSkyKick.NinjectWorkshop.WordCounting -
Create a new Class file called
WebTextSourceto 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 theIWebClientthat we created in the previous step so it doesn't directly interact withSystem.Net.Http.WebClient. Also, we useIWebClientin 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.WordCountingcalledWordCountingEngine: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
WordCountingAlgorithmIWebTextSource. It's Single Responsibility is to callIWebTextSourceand pass its output toWordCountingAlgoirthmthus 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
ReplinSkyKick.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
Programwith: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 isProgramSingle 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
WordCountingEngineand the other concrete classes inSkyKick.NinjectWorkshop.WordCountingare inaccessible because of their protection level.- Temporarily, update
SkyKick.NinjectWorkshop.WordCountingAssemblyInfo.csto allowSkyKick.NinjectWorkshop.WordCounting.UIto accessinternalclasses:[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.WordCountingare 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 compliementAssertlikeShouldEqual()which we'll make use of in our tests. -
Add a NuGet reference to
SkyKick.Bcl.Loggingfrom the SkyKick nuget feed -
Add a NuGet reference to
SkyKick.Bcl.Extensionsfrom the SkyKick nuget feed
-
-
Allow access to Internals for Tests
-
Often Tests will need to access
internalconcrete implementations in order to test them. This is perfectly ok. -
Update
SkyKick.NinjectWorkshop.WordCountingAssemblyInfo.csto allowSkyKick.NinjectWorkshop.WordCounting.Teststo accessinternalclasses:[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
IWebClientand will expect html to comeback. So we'll add a file that contians that markup. -
Create a new Folder in
SkyKick.NinjectWorkshop.WordCounting.TestscalledSampleFiles -
Create a new Text File in
SampleFilescalledTwoWordsHtml.txt:<html><body>Hello World</body></html>
-
In the Solution Explorer, right click on
TwoWordsHtml.txtand select Properties from the Context Menu. In the Properties Window, change the Build Action toEmbedded Resource- This will add
TwoWordsHtml.txtto the compiled Tests dll. UsingSkyKick.Bcl.Extensionsit 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.TestscalledWordCountingEngineTests: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
CountWordsInSampleFilesCorrecltyTest 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 ofIWebClientthat 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 theArgclass 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 theurlpassed toIWebClient.GetHtmlAsyncmatches the one passed toWordCountingEngine.CountWordsOnUrlAsync. IfWordCountingEnginepasses 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 forWordCountingEngineonly replacing theIWebClientwith 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 theShouldlibrary 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
FakeRepositorythat impmements anIRepositoryinterface, 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 (iewordCountingEnginecalled_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.0toSkyKick.NinjectWorkshop.WordCounting.UIif it hasn't already been added. -
Create a new Class at the root of
SkyKick.NinjectWorkshop.WordCounting.UIcalledStartup: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 bestaticand in fact, marking it static could be a deteriment to testability, as we'll see later on. -
The name
Startupis not strictly necessary. It's a convention that I was first expsoed to in ASP.NET Mvc and have since adopted. I like puting theBuildKernelmethod in a class calledStartupbecause 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.csto useStartup.BuildKernelusing 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
Replto 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.MainFor 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, butRepltakes 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 aWordCountingEngineand give them that instance.
- This tells Ninject that whenever anyone needs a
-
Run
SkyKick.NinjectWorkshop.WordCounting.UIi. 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
FooimplementsIFoowe can leverage that convention to automatically add all the bindings! -
Add a NuGet reference to
Ninject.Extensions.Conventions 3.2.0.0toSkyKick.NinjectWorkshop.WordCounting -
Add a new Class to the root of
SkyKick.NinjectWorkshop.WordCountingcalledNinjectModule: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
NinjectModulecan be passed to theStandardKernelconstructor and adds bindings. - Using
Ninject.Extensions.Conventionswe can add our default bindings - all classes that follow the naming conventionFooimplementsIFoowill automatically bind. - By using
IncludingNonePublicTypes()internalclasses will be bound as well. This means we no longer need to leak internal types toSkyKick.NinjectWorkshop.WordCounting.UI
- One or more
-
Update the
AssemblyInfoclass inSkyKick.NinjectWorkshop.WordCounting.Propertiesand 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
IWebClientwas 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
AssemblyInfoclass inSkyKick.NinjectWorkshop.WordCounting.UI.Propertiesand add the line:[assembly: InternalsVisibleTo("SkyKick.NinjectWorkshop.WordCounting.Tests")]
- Create a new Class in the root of
SkyKick.NinjectWorkshop.WordCounting.TestscalledNinjectBindingTests: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
CanLoadRepland confirm that it fails.
- Update the
-
Update
NinjectModulewith a binding forIWebClientusing 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
CanLoadReplTest 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.Loggingincludes aNinjectModulethat 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
CanLoadReplTest- 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
WordCountingEngineTestsso 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 forIWebClientthat's already in the Kernel. - We can use
.ToConstantto 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
CountsWordsInSampleFilesCorrectlyand 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
SampleFilescalledWordsWithEntersAndNoSpaces.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.txtand 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
TestCasetoWordCountingEngineTests.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
IWebClientthrows 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
IWebClientgets 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.0toSkyKick.NinjectWorkshop.WordCounting -
Create a new Class called
WebTextSourceOptionsinSkyKick.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
WebTextSourceOptionsto include default values in a real system.
- It's ok for
-
Update
WebTextSourceto 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
WebTextSourceOptionsis injected. This meansWebTextSourceis 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.Configurationand the new Configuration system in .net Core which provides a DI supported subsytem for configuration
- This pattern aligns very nicely with
-
Note: on the
ExecuteAsynclambda, 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
WebTextSourceTestsinSkyKick.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
WebTextSourceOptionswith an array of 0 second retry times toVerify()that the retry policy is retrying Web Requests -
Use
VerifyAllExpectations()to verifyGetHtmlAsyncwas called the correct number of times
-
-
Run
InvokesRetryPolicyOnErrorsTests-
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. TheActualexception 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 nullwhich leades to theNullReferenceException.
-
-
-
Update InvokesRetryPolicyOnErrors to use a Strict Mock:
var mockWebClient = MockRepository.GenerateStrictMock<IWebClient>();
-
Re-Run
InvokesRetryPolicyOnErrorsTests- We now get a better
Exceptionin the Actual output aExpectationViolationException. Using Strict mocks will have Rhino throw a very specificExceptionif 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
InvokesRetryPolicyOnErrorsTests- 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.WordCountingcalledThreading -
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.ThreadingcalledThreadSleeper: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.WordCountingcalledCache -
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.CachecalledWordCountCache: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
IThreadSleeperto 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
WordCountingEngineto 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
Replclass keeps the full object graph between user input so it's ok thatWordCountCacheis not actually a singleton.
-
-
Create a Guard Test for
WordCountCache-
Event though
SkyKick.NinjectWorkshop.WordCounting.UIisn't using the cache from multiple requests,SkyKick.NinjectWorkshop.WordCountingmight 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.TestscalledCache -
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.Tests.CachecalledWordCountCacheTests: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.Warnit'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
IThreadSleeperwe must useRebind(). theSkyKick.NinjectWorkshop.WordCounting.NinjectModulealready 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
Rebindif 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
WordCountCacheShouldBeBoundAsASingletonTest 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
Rebindin this case, because theSelectAllClasses().BindDefaultInterface()will include a default binding forIWordCountCachethat we'll need to replace.
-
-
Re-Run the
WordCountCacheShouldBeBoundAsASingletonTest-
Confirm the test now passes!
-
An interesting thing to note, is we only set
InSingletonScope()whenIWordCountCacheis requested. If you were to change the test to requestWordCountCacheit 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:
IUserReadRepositoryandIUserWriteRepository. And both interfaces are implemented byUserRepository. IfUserRepositoryneeded 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
WordCountingEngineTestsPerformance-
You might have noticed that our cross component tests are now running a lot longer -
WordCountingEngineis 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
IThreadSleeperwrapper.
- This is another example where we can take advantage of the benefit of having the
-
-
Re-Run
WordCountingEngineTestsand 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.WordCountingcalledITextSource: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
TextSourceIdfor logging / cache key
-
-
Update
WebTextSourceto 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
urlin 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.HttpcalledWebTextSourceFactory: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
WebTextSourceneeds, 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
newkeyword again, but this is perfectly ok.
-
-
Update
WordCountingEngineto 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.WordCountingcalledFile -
Add a NuGet reference to
SkyKick.Bcl.ExtensionsinSkyKick.NinjectWorkshop.WordCountingfrom the SkyKick nuget feed -
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.FilecalledFileTextSource: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.IFileto pull in an existing abstraction around the File System.
- We'll use
-
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.FilecalledIFileTextSourceFactory:namespace SkyKick.NinjectWorkshop.WordCounting.File { public interface IFileTextSourceFactory { IFileTextSource CreateFileTextSource(string path); } }
- For
IFileTextSourceFactorywe'll use a plugin to avoid having to write the boiler plate factory code we wrote inWebTextSourceFactorythat pulled in the dependencies and passed them to theWebTextSouceconstructor.- This plugin will use a number of conventions. Method must start with
Createand we must create aIFileTextSourceto 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.0inSkyKick.NinjectWorkshop.WordCounting -
Update
SkyKick.NinjectWorkshop.WordCounting.NinjectModulewith 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.Repland 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.UIcalledRepl -
Move the
Replclass file into theReplfolder. -
Update the namespace in the
Replclass toSkyKick.NinjectWorkshop.WordCounting.UI.Repl
-
-
Add a new Class to
SkyKick.NinjectWorkshop.WordCounting.UI.ReplcalledTextSources:namespace SkyKick.NinjectWorkshop.WordCounting.UI.Repl { public enum TextSources { File = 1, Web = 2 } }
-
Add a new Class to
SkyKick.NinjectWorkshop.WordCounting.UI.ReplcalledReplTextSourceBuilder: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.Replto 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.0toSkyKick.NinjectWorkshop.WordCounting.UI -
We're now injecting a
IReplTextSourceBuilderintoRepl. We don't have a Ninject Module forSkyKick.NinjectWorkshop.WordCounting.UIsoReplwill no longer resolve correclty.-
Add a new Class to
SkyKick.NinjectWorkshop.WordCounting.UIcalledNinjectModule: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
Startupto 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
WordCountingEngineCross 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.WordCountingcalledEmail -
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.EmailcalledEmailClient: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.WordCountingcalledWordCountingWorkflow: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( "to@skykick.com", "no-reply@skykick.com", "Less than 1000", token); else await _emailClient .SendEmailAsync( "to@skykick.com", "no-reply@skykick.com", "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
Replto 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.TestscalledHelpers -
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.Tests.HelperscalledWebExceptionHelper: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:
CreateWebExceptionWithStatusCodeis a copy of the private method that was inWebTextSourceTests. Remove the private method fromWebTextSourceTestsand update the Tests in that file to useWebExceptionHelper.
- Optional:
-
Add a NuGet reference to
Ninject.MockingKernel.RhinoMocks 3.2.2.0toSkyKick.NinjectWorkshop.WordCounting.Tests -
Update the NuGet reference to
Ninject.MockingKernelto3.2.2.0inSkyKick.NinjectWorkshop.WordCounting.TestsNinject.MockingKernel.RhinoMocksautomatically 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.TestscalledWordCountingWorkflowTests: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(); } } }
-
WordCountingWorkflowhas 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
ILoggereven thoughWordCountWorkflowtakes 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\SampleFilescalled3000Words.txtand copy the contents from: 3000Words.txt - Create a new File in
SkyKick.NinjectWorkshop.WordCounting.Tests\SampleFilescalled500Words.txtand copy the contents from: 500Words.txt
- Create a new File in
-
Create a new Class in
SkyKick.NinjectWorkshop.WordCounting.TestscalledWordCountingWorkflowScenarioTests: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
TestHarnesswill 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
WebClientandEmailClient, so we're fully runningWordCountingWorkflow,WordCountingEngine,WordCountingAlgorithm,WordCountCache, andWebTextSource -
Note how
VerifyTheOnlyEmailSentHaddoes 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
TestHarnessso 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
TestHarnessand initialize it with theWebSiteHasHtml. -
The When step is executed in the
WhenTheWordCountingWorkflowIsRunmethod, 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
WordCountingWorkflowScenarioTeststo 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
TestHarnessis 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!