So I got a good night's sleep and had a nice workout this morning and came up with a some great refactorings for TDD with Javascript.
I have simplified the code base tremendously and added to the expressiveness.
The basic idea for my TDD/JS framework is that you still use NUnit/TestDriven.Net. Yesterday's version used tests that were implemented in javascript files, after thinking about it, I've decided to implement the tests in actual C# files as you will see in a few seconds.
First let's go over the problem... TDD with Javascript is HARD and a pain in the ass. I don't want to work in the browser to do my TDD because that means I have to leave the environment I love so much (VS2008 IDE). I don't want to reinvent the wheel either... and I love TestDriven.Net's ease of testing...
So the current solution goes as follows:
1. We have an abstract base class called JavascriptTest which your test classes will inherit from.
2. Your test classes will continue to work very similarly to how they do in the C# world
Let's dig into the code
First I will show you the test class that I have been using while I developed the framework - it's ridiculously simple and probably doesn't make any sense...
[TestFixture]
public class TimeTest : JavascriptTest
{
protected override void AddScripts(ScriptManager scripts)
{
scripts.AddScriptFile(@"C:\Projects\Web\js\Time.js");
}
[Test]
public void TodayReturnsToday()
{
Test(
"var t = new Time();" +
"assert.areEqual('today', t.today());"
);
}
[Test]
public void TomorrowReturnsTomorrow()
{
Test(
"var t = new Time();" +
"assert.areEqual('tomorrow', t.tomorrow());"
);
}
}
You see the normal NUnit attributes applied and that we inherit from the JavascriptTest class. There is a mandatory method that we override called AddScripts which is used to tell the framework where the javascripts that we want to test are located. Currently, I am pointing that to a javascript file in a web project in the same solution.
The test methods have the same Test attribute you would normally apply, but they use the Test function on the JavascriptTest base to run the test. This was a design decision that I'm not 100% sure on yet, but I think the benefits of the actual tests being run in NUnit outweights having to concat strings to build your test scripts. Let's take a look at the JavascriptTest class now.
[TestFixture]
public abstract class JavascriptTest
{
private ScriptManager scripts;
private JavascriptHost host;
protected abstract void AddScripts(ScriptManager scripts);
[TestFixtureSetUp()]
public void TestFixtureSetUp()
{
scripts = new ScriptManager();
AddScripts(scripts);
host = new JavascriptHost(scripts);
}
protected void Test(string javascriptTest)
{
host.RunTest(javascriptTest);
}
[TestFixtureTearDown()]
public void TestFixtureTearDown()
{
host.Dispose();
}
}
This class is very simple and delegates most of its work to the JavascriptHost class. We have an instance of a ScriptManager class and a JavascriptHost class. We have a TestFixtureSetUp method where we initialize our host and our script manager and load the scripts from our derived classes. In the tear down we cleanup. The Test method which the clients call with the javascript test is passed on to the host.
Let's take a look at the ScriptManager class.
public class ScriptManager
{
private IList scripts;
public ScriptManager()
{
this.scripts = new List();
}
public void AddScriptText(string script)
{
this.scripts.Add(script);
}
public void AddScriptFolder(string jsFolder)
{
foreach (FileInfo file in new DirectoryInfo(jsFolder).GetFiles("*.js"))
{
AddScriptFile(file.FullName);
}
}
public void AddScriptFile(string jsFile)
{
AddScriptText(File.ReadAllText(jsFile));
}
public string ToJavascript()
{
return this.scripts.Aggregate((all, curr) => all + "\r\n" + curr);
}
}
This class has only one purpose, to collect the scripts that the client wants to test. It has convenience methods to load scripts or an entire folder. This class will grow to add recursive directory searching etc, but for now it's fairly simple. It has one method ToJavascript() which concatenates all the scripts into a single script. NOTE: This class does not store file references, it stores the actual scripts located in the files. Our host class will be injecting javascript not links to the javascript files.
The majority of the work is done in our JavascriptHost class so let's look at that.
[ComVisible(true)]
public class JavascriptHost : Form
{
private WebBrowser webBrowser;
private ScriptManager scripts;
public JavascriptAssert Assert { get; private set; }
public JavascriptHost(ScriptManager scripts)
{
this.webBrowser = new WebBrowser();
this.webBrowser.ObjectForScripting = this;
this.webBrowser.ScriptErrorsSuppressed = true;
this.scripts = scripts;
this.Assert = new JavascriptAssert();
this.Controls.Add(this.webBrowser);
}
public void RunTest(string javascriptTest)
{
BuildWebPage();
InjectScript(javascriptTest);
InvokeScript();
}
private void InvokeScript()
{
webBrowser.Document.InvokeScript("test");
Assert.CheckAssertions();
}
private void InjectScript(string javascriptTest)
{
HtmlElement body = webBrowser.Document.GetElementsByTagName("body")[0];
HtmlElement scriptElement = webBrowser.Document.CreateElement("script");
IHTMLScriptElement element = (IHTMLScriptElement)scriptElement.DomElement;
StringBuilder script = new StringBuilder();
script.AppendLine("function test() {");
script.Append(javascriptTest);
script.Append("}");
element.text = script.ToString();
body.AppendChild(scriptElement);
Assert.CheckAssertions();
}
private void BuildWebPage()
{
this.webBrowser.DocumentText = GetPageHtml();
while (this.webBrowser.ReadyState != WebBrowserReadyState.Complete)
{
Application.DoEvents();
}
Assert.CheckAssertions();
}
private string GetPageHtml()
{
StringBuilder html = new StringBuilder();
StringBuilder html = new StringBuilder();
html.AppendLine("< html>");
html.AppendLine("< head>");
html.AppendLine("< script type=\"text/javascript\">");
html.AppendLine("assert = window.external.assert;");
html.AppendLine("window.onerror = function(msg) { assert.fail(msg); }");
html.AppendLine("< /script");
html.AppendLine("< body>");
html.AppendLine("< /body>");
html.AppendLine("< script type=\"text/javascript\">");
html.Append(scripts.ToJavascript());
html.AppendLine("< /script>");
html.AppendLine("");
return html.ToString();
return html.ToString();
}
}
NOTE: tags have been modified in the HTML so that they show up in this blog by adding a space between the < and the tag name
As you can see, this class is a bit longer than the rest. First things first, this class inherits from Windows Form - this is necessary to host our WebBrowser control which will do all the work for us. At construction, the class creates a new WebBrowser control and adds it to its child controls. It disables Script errors so that we don't get popups and sets the ObjectForScripting property to itself. This property will be our window.external reference in our javascripts. Basically, it will allow our scripts to call back to the host. We have added a ComVisible attribute to the host to allow this access.
You will also see that we create a JavascriptAssert class - we'll get to this in a minute.
The RunTest method is our only public method and is invoked by the JavascriptTest class. The method basically does the following things:
1. Build a HTML document that will form the page that our WebBrowser will load (using the DocumentText property).
2. Inject our test script at the end of the web page.
3. Call our test script.
In the BuildWebPage you will see that we construct our page. The HTML generated starts with a script at the top of the page. The first line sets a local variable assert to the window.external.assert property. This is so our scripts can references assert.areEqual etc. and will actually be calling into our host's JavascriptAssert class via the Assert property on the JavascriptHost. Remember, our JavascriptHost is the window.external reference
Next thing is the window.onerror = function... script. This is used so that any subsequent scripts which cause errors (either parser errors or scripting errors) will go through our assert.fail and call back into our managed code.
The only other thing added is the scripts being tested after the body.
Next thing we do is inject our test script (the script that actually exercises our javascript) at the very end of the document. We do this by dynamically creating a script element, setting its text property to the script passed by our client (wrapped in a function called "test") and we add this element to our page.
Finally, we call our "test" function by using the InvokeScript method on the HtmlDocument.
You probably have noticed the Assert.CheckAssertions littered all over, so it's time to go over how we actually integrate with the NUnit framework... let's take a look at the JavascriptAssert class.
[ComVisible(true)]
public class JavascriptAssert
{
private Exception current;
private void Try(Action action)
{
try
{
action();
}
catch (Exception ex)
{
current = ex;
}
}
public void AreEqual(object expected, object actual)
{
Try(() => Assert.AreEqual(expected, actual));
}
public void Fail(string message)
{
Try(() => Assert.Fail(message));
}
public void CheckAssertions()
{
if (current != null) throw current;
}
}
Once again you see the ComVisible attribute, this makes the class visible to our Javascript which is important because of the Assert property on the JavascriptHost. When a javascript calls assert.AreEqual(...) it is actually calling window.external.assert.areEqual. window.external references our javascript host which then resolves the Assert property to our JavascriptAssert class.
The methods on the JavascriptAssert class are basically going do coincide with NUnit's method (although we've only done 2 so far). You will notice we wrap our NUnit Assert calls in the Try method which actually captures any exception they might throw and stores it in a current variable. The CheckAssertions method simply throws the exception if there is one.
The reason we do it this way is due to threading... Our JavascriptHost is running in the same thread as NUnit so calls to Assert.Fail etc. work as expected if they are called directly from that thread, however, our webbrowser is running javascript in a separate thread and calls back into our assert call nunit, but does not register with the testing framework correctly. This is why we have the CheckAssertions after any point where an exception could have been raised. It causes the exception to be rethrown on the correct thread and have Nunit function properly.
To show you how cool this is, I'm going to show you the output after using TestDriven.Net on the test file (above) in the following scenarios:
1. Test file does not yet exist
2. Test file exists, but Time class has not been created yet
3. Time class has been created, but the method has not yet been implemented
4. Method implemented but returns wrong value
5. Correct implementation
1. No Test File
TestCase 'Evo.Contact.Tests.Web.TimeTest.TodayReturnsToday' failed: TestFixtureSetUp failed in TimeTest
TestCase 'Evo.Contact.Tests.Web.TimeTest.TomorrowReturnsTomorrow' failed: TestFixtureSetUp failed in TimeTest
TestFixture failed: System.IO.FileNotFoundException : Could not find file 'C:\Evo\Projects\Contact\src\Web\js\Time.js'.
2. Blank Test File
TestCase 'Evo.Contact.Tests.Web.TimeTest.TodayReturnsToday' failed: 'Time' is undefined
TestCase 'Evo.Contact.Tests.Web.TimeTest.TomorrowReturnsTomorrow' failed: 'Time' is undefined
3. Time.js below
Time = function() {
};
Output
TestCase 'Evo.Contact.Tests.Web.TimeTest.TodayReturnsToday' failed: Object doesn't support this property or method
TestCase 'Evo.Contact.Tests.Web.TimeTest.TomorrowReturnsTomorrow' failed: Object doesn't support this property or method
4. Method Implemented with incorrect return value
Time = function() {
};
Time.prototype.today = function() { return "" };
Time.prototype.tomorrow = function() { return "" };
Output
TestCase 'Evo.Contact.Tests.Web.TimeTest.TodayReturnsToday'
failed:
Expected string length 5 but was 0. Strings differ at index 0.
Expected: "today"
But was:
TestCase 'Evo.Contact.Tests.Web.TimeTest.TomorrowReturnsTomorrow'
failed:
Expected string length 5 but was 0. Strings differ at index 0.
Expected: "today"
But was:
And finally, the correct implementation
Time = function() {
};
Time.prototype.today = function() { return "today" };
Time.prototype.tomorrow = function() { return "tomorrow" };
Output
2 passed, 0 failed, 0 skipped, took 1.19 seconds.
Now I'm not sure about you guys, but that's pretty awesome. We are testing javascript using familiar tools (NUnit and TestDriven.net) and I am running the tests directly within my IDE without any alt-tabbing!
Check out the code