├── .gitignore ├── .vscode └── launch.json ├── README.md ├── appveyor.yml ├── build ├── Build.csx ├── Command.csx └── ScriptUnit.nuspec ├── omnisharp.json └── src ├── Examples ├── Calculator.csx └── CalculatorTests.csx ├── ScriptUnit.Tests ├── ScriptUnitTests.csx └── TopLevelTests.csx └── ScriptUnit └── ScriptUnit.csx /.gitignore: -------------------------------------------------------------------------------- 1 | build/tmp -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": ".NET Script Debug", 6 | "type": "coreclr", 7 | "request": "launch", 8 | "program": "dotnet", 9 | "args": [ 10 | "exec", 11 | "/Users/bernhardrichter/.dotnet/tools/.store/dotnet-script/0.28.0/dotnet-script/0.28.0/tools/netcoreapp2.1/any/dotnet-script.dll", 12 | "${file}" 13 | ], 14 | "cwd": "${workspaceRoot}", 15 | "stopAtEntry": true 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScriptUnit 2 | A super simple test runner for C# scripts. 3 | 4 | Once we start to create reusable scripts that is to be consumed by other scripts, it makes sense to verify that these scripts actually do what they are intended to do. Scripts are really no different from regular code and that makes it a perfect target for unit testing. 5 | 6 | > Fun fact: **ScriptUnit** is also just a script with its own set of unit tests executed by itself. Isn't that nice, code that test itself :) 7 | 8 | **ScriptUnit** does not come with an API for assertions so we are free to use any assertion library available. 9 | In the following examples we will be using [FluentAssertions](http://fluentassertions.com/). 10 | 11 | ### Writing Tests 12 | 13 | The first thing we need to do is to somehow reference **ScriptUnit** along with an assertion library. 14 | 15 | ```c# 16 | #load "ScriptUnit.0.1.0\contentFiles\csx\any\main.csx" 17 | #r "FluentAssertions.4.19.4\lib\net45\FluentAssertions.dll" 18 | ``` 19 | 20 | If [Dotnet-Script](https://github.com/filipw/dotnet-script) is used to execute the scripts, we can bring in these dependencies as inline NuGet references 21 | 22 | ```c# 23 | #load "nuget:ScriptUnit, 0.1.0" 24 | #r "nuget:FluentAssertions, 4.19.4" 25 | ``` 26 | 27 | > The advantage of using **Dotnet-Script** is that we can also debug unit tests, but as we can see, **ScriptUnit** does not require a specific script runner. 28 | 29 | 30 | 31 | A test class (fixture) is just a regular class where the default is that we consider all public methods as test methods (cases). **ScriptUnit** will create an instance of the test class and execute all test methods in no particular order. 32 | 33 | **SampleTests.csx** 34 | 35 | ```c# 36 | #load "nuget:ScriptUnit, 0.1.0" 37 | #r "nuget:FluentAssertions, 4.19.4" 38 | 39 | using static ScriptUnit; 40 | using FluentAssertions; 41 | 42 | return await AddTestsFrom().Execute(); 43 | 44 | public class SampleTests() 45 | { 46 | public void Success() 47 | { 48 | "Ok".Should().Be("Ok"); 49 | } 50 | 51 | public void Fail() 52 | { 53 | "Ok".Should().NotBe("Ok"); 54 | } 55 | } 56 | ``` 57 | 58 | ### Setup and Tear down 59 | 60 | Tests classes (fixtures) that shares state across test methods can do initialization in the constructor of the test class. For "tear down", simply implement `IDisposable`. 61 | 62 | ```c# 63 | public class SampleTests : IDisposable 64 | { 65 | public SampleTests() 66 | { 67 | //Do init here.. 68 | } 69 | 70 | public void Dispose() 71 | { 72 | //Do "tear down" here-- 73 | } 74 | } 75 | ``` 76 | 77 | ### Top-level tests 78 | 79 | Tests does not even need to be in a class. We can just start to write test methods directly in the script. 80 | 81 | ```c# 82 | return await AddTopLevelTests().AddFilter(m => m.Name.StartsWith("Should")); 83 | 84 | public void ShouldBeOk() 85 | { 86 | "Ok".Should().Be("Ok"); 87 | } 88 | ``` 89 | 90 | 91 | 92 | ### Data driven test 93 | 94 | Test method that has parameters can be executed with a set of arguments using the `Arguments` attribute. 95 | 96 | ```c# 97 | [Arguments(1,2,3)] 98 | [Arguments(2,3,5)] 99 | public void ShouldAddNumbers3(int value1, int value2, int result) 100 | { 101 | Add(value1,value2).Should().Be(result); 102 | } 103 | ``` 104 | 105 | Arguments passed to test methods can also come from a different source. 106 | 107 | ```c# 108 | .WithArgumentProvider(testMethod => { 109 | //Return arguments here 110 | }); 111 | ``` 112 | 113 | When presenting the test result for data driven tests, we will simply execute `ToString()` on each argument. 114 | 115 | ```shell 116 | CalculatorTests.ShouldAddNumbers3 33ms 117 | * CalculatorTests.ShouldAddNumbers3(1, 2, 3) 118 | 31ms 119 | * CalculatorTests.ShouldAddNumbers3(2, 3, 5) 120 | 0ms 121 | Total tests: 2. Passed: 2. Failed: 0. 122 | Test Run Successful. 123 | Test execution time 0,0402419 seconds 124 | 125 | ``` 126 | 127 | If we should a more sophisticated formatting of arguments, we can do this using the `WithArgumentsFormatter` method. 128 | 129 | ```c# 130 | .WithArgumentsFormatter(arguments => { 131 | // Do something else than ToString() here 132 | }); 133 | ``` 134 | 135 | 136 | 137 | ### Test Execution 138 | 139 | In addition to the actual test class and its test methods we need something to actually run the tests 140 | 141 | ```c# 142 | return await AddTestsFrom().Execute(); 143 | ``` 144 | 145 | This piece of code is written directly inside the script file and will be executed when we execute the script using script runner of choice. 146 | 147 | **csi.exe** 148 | 149 | ```shell 150 | csi Sampletests.csx 151 | ``` 152 | 153 | **DotNet-Script** 154 | 155 | ```shell 156 | dotnet script SampleTests.csx 157 | ``` 158 | 159 | The return value from `Execute` and `ExecuteInParallel` is used as the exit code. 160 | 161 | 162 | 163 | ### Test Filtering 164 | 165 | The default is to execute all public methods found in the test class, but we can also choose to filter these methods into a subset like this. 166 | 167 | ```c# 168 | return await AddTestsFrom().WithFilter(testMethod => testMethod.Name.StartsWith("Should")).Execute(); 169 | ``` 170 | 171 | > Note: We could also use this to filter methods based on an attribute like xUnit does with its `Fact` attribute. 172 | 173 | 174 | 175 | To execute a single test we can filter test methods down to a single test. 176 | 177 | ````C# 178 | return await AddTestsFrom().WithFilter(f => c.ShouldAddNumbers()).Execute(); 179 | ```` 180 | 181 | ### Parallelization 182 | 183 | **ScriptUnit** can execute test fixtures in parallel using the `ExecuteInParallel`method. Test methods within the same test class (fixture) are not executed in parallel. 184 | 185 | ```c# 186 | return await AddTestsFrom() 187 | .AddTestsFrom() 188 | .ExecuteInParallel(); 189 | ``` 190 | 191 | 192 | 193 | ### Standard Out/Error 194 | 195 | **ScriptUnit** captures `Console.Out` and `Console.Error` and will by default output these streams when formatting the test results. 196 | 197 | ```c# 198 | public void WriteToConsole() 199 | { 200 | Console.WriteLine("This text was written to standard out"); 201 | Console.Error.WriteLine("This text was written to standard error"); 202 | } 203 | ``` 204 | 205 | Running this test will yield the following output. 206 | 207 | ```shell 208 | SampleTests.WriteToConsole 0ms 209 | Standard Out 210 | This text was written to standard out 211 | 212 | Standard Error 213 | This text was written to standard error 214 | 215 | Total tests: 1. Passed: 0. Failed: 0. 216 | Test Run Successful. 217 | Test execution time 0 seconds 218 | ``` 219 | 220 | We can also get access to the text written to `Console.Out` and ´Console.Error` from within a test method. 221 | 222 | ```C# 223 | public void WriteToConsole() 224 | { 225 | Console.WriteLine("This test was written to standard out"); 226 | TestContext.StandardOut.Should().Contain("This text was written to standard out") 227 | 228 | ``` 229 | 230 | ### Custom Formatting 231 | 232 | **ScriptUnit** outputs a console friendly test result summary, but we can still create our own summary formatter that replaces the default output. 233 | 234 | ```c# 235 | .WithSummaryFormatter(summary => { 236 | //Process the summary here 237 | }); 238 | ``` 239 | 240 | This can for instance be used to create a different console output or it can be used to output the summary in a different format such as Markdown. -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | version: 1.0.{build} 2 | image: Visual Studio 2017 3 | 4 | build_script: 5 | - ps: >- 6 | choco install dotnet.script 7 | - cmd: >- 8 | cd build 9 | 10 | refreshenv 11 | 12 | SET PATH=C:\Ruby24\bin;%PATH% 13 | 14 | dotnet script build.csx 15 | 16 | test: off 17 | environment: 18 | GITHUB_REPO_TOKEN: 19 | secure: FSPXTPuTgFMaZA7DubJoX217SkWhFLN2BGqCCi4gBux967eFtwkhbrafm7ay8cP2 20 | NUGET_APIKEY: 21 | secure: ynFcRQX0oim3DdR5Y8s4BtynS/NYRG059GvWGckqhpZGNZVvhvvn5UUWgsyPKLKm -------------------------------------------------------------------------------- /build/Build.csx: -------------------------------------------------------------------------------- 1 | #! "netcoreapp2.0" 2 | #load "Command.csx" 3 | 4 | using System.Runtime.CompilerServices; 5 | 6 | var scriptFolder = GetScriptFolder(); 7 | var tempFolder = Path.Combine(scriptFolder,"tmp"); 8 | RemoveDirectory(tempFolder); 9 | 10 | var contentFolder = Path.Combine(tempFolder,"contentFiles","csx","any"); 11 | Directory.CreateDirectory(contentFolder); 12 | 13 | File.Copy(Path.Combine(scriptFolder,"..","src","ScriptUnit","ScriptUnit.csx"), Path.Combine(contentFolder,"main.csx")); 14 | File.Copy(Path.Combine(scriptFolder,"ScriptUnit.nuspec"),Path.Combine(tempFolder,"ScriptUnit.nuspec")); 15 | 16 | string pathToUnitTests = Path.Combine(scriptFolder,"..","src","ScriptUnit.Tests","ScriptUnitTests.csx"); 17 | Command.Execute("dotnet", $"script {pathToUnitTests}"); 18 | 19 | string pathToTopLevelTests = Path.Combine(scriptFolder,"..","src","ScriptUnit.Tests","TopLevelTests.csx"); 20 | Command.Execute("dotnet", $"script {pathToTopLevelTests}"); 21 | 22 | Command.Execute("nuget",$"pack {Path.Combine(tempFolder,"ScriptUnit.nuspec")} -OutputDirectory {tempFolder}"); 23 | 24 | 25 | 26 | static string GetScriptPath([CallerFilePath] string path = null) => path; 27 | static string GetScriptFolder() => Path.GetDirectoryName(GetScriptPath()); 28 | 29 | static void RemoveDirectory(string path) 30 | { 31 | if (!Directory.Exists(path)) 32 | { 33 | return; 34 | } 35 | 36 | // http://stackoverflow.com/questions/329355/cannot-delete-directory-with-directory-deletepath-true 37 | foreach (string directory in Directory.GetDirectories(path)) 38 | { 39 | RemoveDirectory(directory); 40 | } 41 | 42 | try 43 | { 44 | Directory.Delete(path, true); 45 | } 46 | catch (IOException) 47 | { 48 | Directory.Delete(path, true); 49 | } 50 | catch (UnauthorizedAccessException) 51 | { 52 | Directory.Delete(path, true); 53 | } 54 | } -------------------------------------------------------------------------------- /build/Command.csx: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Threading; 3 | using System.Text.RegularExpressions; 4 | 5 | public static class Command 6 | { 7 | public static void Execute(string commandPath, string arguments) 8 | { 9 | var startInformation = CreateProcessStartInfo(commandPath, arguments); 10 | var process = CreateProcess(startInformation); 11 | RunAndWait(process); 12 | 13 | if (process.ExitCode != 0) 14 | { 15 | throw new InvalidOperationException("Command failed"); 16 | } 17 | } 18 | 19 | private static ProcessStartInfo CreateProcessStartInfo(string commandPath, string arguments) 20 | { 21 | var startInformation = new ProcessStartInfo($"{commandPath}"); 22 | startInformation.CreateNoWindow = true; 23 | startInformation.Arguments = arguments; 24 | startInformation.RedirectStandardOutput = true; 25 | startInformation.RedirectStandardError = true; 26 | startInformation.UseShellExecute = false; 27 | return startInformation; 28 | } 29 | 30 | private static void RunAndWait(Process process) 31 | { 32 | process.Start(); 33 | process.BeginErrorReadLine(); 34 | process.BeginOutputReadLine(); 35 | process.WaitForExit(); 36 | } 37 | private static Process CreateProcess(ProcessStartInfo startInformation) 38 | { 39 | var process = new Process(); 40 | process.StartInfo = startInformation; 41 | process.OutputDataReceived += (s,e) => WriteLine(e.Data); 42 | process.ErrorDataReceived += (s,e) => Error.WriteLine(e.Data); 43 | return process; 44 | } 45 | } -------------------------------------------------------------------------------- /build/ScriptUnit.nuspec: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ScriptUnit 5 | ScriptUnit 6 | 0.2.0 7 | A super simple C# script test runner 8 | Bernhard Richter 9 | Bernhard Richter 10 | https://github.com/seesharper/ScriptUnit 11 | https://opensource.org/licenses/MIT 12 | C# UnitTesting Scripting 13 | 14 | -------------------------------------------------------------------------------- /omnisharp.json: -------------------------------------------------------------------------------- 1 | { 2 | "script": { 3 | "enableScriptNuGetReferences": true, 4 | "defaultTargetFramework": "netcoreapp2.1" 5 | } 6 | } -------------------------------------------------------------------------------- /src/Examples/Calculator.csx: -------------------------------------------------------------------------------- 1 | #!netcoreapp2.0 2 | 3 | public static int Add(int value1, int value2) 4 | { 5 | return value1 + value2; 6 | } 7 | -------------------------------------------------------------------------------- /src/Examples/CalculatorTests.csx: -------------------------------------------------------------------------------- 1 | #load "Calculator.csx" 2 | #load "../ScriptUnit/ScriptUnit.csx" 3 | #r "nuget:FluentAssertions, 4.19.4" 4 | 5 | using System.Threading; 6 | using FluentAssertions; 7 | using static ScriptUnit; 8 | return await AddTestsFrom().Execute(); 9 | 10 | public class CalculatorTests 11 | { 12 | 13 | public void ShouldAddNumbers() 14 | { 15 | var result = Add(2,2); 16 | result.Should().Be(4); 17 | } 18 | 19 | [Arguments(1,2,3)] 20 | [Arguments(2,3,5)] 21 | public void ShouldAddNumbersUsingArguments(int value1, int value2, int result) 22 | { 23 | Add(value1,value2).Should().Be(result); 24 | } 25 | } -------------------------------------------------------------------------------- /src/ScriptUnit.Tests/ScriptUnitTests.csx: -------------------------------------------------------------------------------- 1 | #load "../ScriptUnit/ScriptUnit.csx" 2 | #r "nuget:FluentAssertions, 4.19.4" 3 | using FluentAssertions; 4 | 5 | using static ScriptUnit; 6 | await AddTestsFrom().Execute(); 7 | 8 | public class ScriptUnitTests 9 | { 10 | public async Task ShouldReportInnerException() 11 | { 12 | await AddTestsFrom() 13 | .WithSummaryFormatter(summary => summary.TestResults.SelectMany(tr => tr.TestCaseResults).Should().OnlyContain(tc => tc.Exception is Exception)) 14 | .Execute(); 15 | } 16 | 17 | public async Task ShouldCaptureStandardOutAndStandardError() 18 | { 19 | await AddTestsFrom() 20 | .WithSummaryFormatter(summary => 21 | { 22 | summary.TestResults.Single().TestCaseResults.Single().StandardOut.Should().Be("This is the output from stdOut"); 23 | summary.TestResults.Single().TestCaseResults.Single().StandardError.Should().Be("This is the output from stdErr"); 24 | }) 25 | .Execute(); 26 | } 27 | 28 | public async Task ShouldCallDisposeMethod() 29 | { 30 | await AddTestsFrom().Execute(); 31 | DisposableTests.Disposed.Should().BeTrue(); 32 | } 33 | 34 | public async Task ShouldReportFixtureNameWithOutSubMissionPrefix() 35 | { 36 | await AddTestsFrom() 37 | .WithSummaryFormatter(summary => 38 | { 39 | summary.TestResults.Single().Fixture.Should().NotStartWith("Submission#0+"); 40 | }) 41 | .Execute(); 42 | } 43 | 44 | public async Task ShouldRunTestsInParallel() 45 | { 46 | await AddTestsFrom() 47 | .AddTestsFrom() 48 | .WithSummaryFormatter(summary => summary.TotalDuration.TotalMilliseconds.Should().BeLessThan(1000)) 49 | .ExecuteInParallel(); 50 | 51 | } 52 | 53 | public async Task ShouldExecuteTestsWithParameters() 54 | { 55 | await AddTestsFrom() 56 | .WithSummaryFormatter(summary => summary.TestResults.Single().TestCaseResults.Count().Should().Be(3)).Execute(); 57 | } 58 | 59 | public async Task ShouldReportStandardOutAndStandardError() 60 | { 61 | await AddTestsFrom().Execute(); 62 | var r = TestContext.StandardOut; 63 | TestContext.StandardOut.Should().Contain("This is the output from stdOut"); 64 | TestContext.StandardOut.Should().Contain("This is the output from stdErr"); 65 | } 66 | } 67 | 68 | public class ExceptionTests 69 | { 70 | public void FailingTest() 71 | { 72 | throw new Exception(); 73 | } 74 | 75 | public Task FailingTestAsync() 76 | { 77 | throw new Exception(); 78 | } 79 | } 80 | 81 | public class ConsoleTests 82 | { 83 | public async Task WriteToStandardOutAndStandardError() 84 | { 85 | Console.Out.Write("This is the output from stdOut"); 86 | await Task.Delay(100); 87 | Console.Error.Write("This is the output from stdErr"); 88 | var test = TestContext.StandardOut; 89 | } 90 | } 91 | 92 | public class DisposableTests : IDisposable 93 | { 94 | 95 | public static bool Disposed { get; private set; } 96 | public void Dispose() 97 | { 98 | Disposed = true; 99 | } 100 | } 101 | 102 | public class LongRunningTests 103 | { 104 | public async Task WaitForHalfASecond() 105 | { 106 | await Task.Delay(500); 107 | } 108 | } 109 | 110 | public class AnotherLongRunningTests 111 | { 112 | public async Task WaitForHalfASecond() 113 | { 114 | await Task.Delay(500); 115 | } 116 | } 117 | 118 | 119 | public class DataDrivenTests 120 | { 121 | [Arguments(1,2,3)] 122 | [Arguments(2,3,5)] 123 | [Arguments(2,3,5)] 124 | public async Task AddNumbers(int value1, int value2, int expected) 125 | { 126 | 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/ScriptUnit.Tests/TopLevelTests.csx: -------------------------------------------------------------------------------- 1 | #load "../ScriptUnit/ScriptUnit.csx" 2 | #r "nuget:FluentAssertions, 4.19.4" 3 | using FluentAssertions; 4 | using static ScriptUnit; 5 | 6 | await AddTestsFrom().Execute(); 7 | 8 | public class TopLevelTests 9 | { 10 | public async Task ShouldExecuteTopLevelTest() 11 | { 12 | await new TestRunner().AddTopLevelTests().AddFilter(m => m.Name.StartsWith("Should")) 13 | .WithSummaryFormatter(summary => summary.TestResults.Single().TestCaseResults.Single().StandardOut.Should().Be("Hello from toplevel test")).Execute(); 14 | } 15 | } 16 | 17 | 18 | public void ShouldWriteToConsole() 19 | { 20 | Console.Write("Hello from toplevel test"); 21 | } -------------------------------------------------------------------------------- /src/ScriptUnit/ScriptUnit.csx: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | The MIT License (MIT) 3 | Copyright (c) 2016 bernhard.richter@gmail.com 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | SOFTWARE. 19 | ****************************************************************************** 20 | ScriptUnit version 0.1.3 21 | https://github.com/seesharper/ScriptUnit 22 | http://twitter.com/bernhardrichter 23 | ******************************************************************************/ 24 | 25 | using System; 26 | using System.Collections.Generic; 27 | using System.Diagnostics; 28 | using System.IO; 29 | using System.Linq; 30 | using System.Linq.Expressions; 31 | using System.Reflection; 32 | using System.Text; 33 | using System.Threading; 34 | using System.Threading.Tasks; 35 | 36 | 37 | Action action = () => Dummy(); 38 | 39 | private void Dummy(){} 40 | 41 | ScriptUnit.TestRunner.Initialize(action.Target); 42 | 43 | /// 44 | /// Serves as a namespace since there are no namespaces in scripts. 45 | /// 46 | public static class ScriptUnit 47 | { 48 | /// 49 | /// Adds tests from the given type. 50 | /// 51 | /// The type for which to add tests. 52 | /// The used to execute the tests. 53 | public static TestRunner AddTestsFrom() 54 | { 55 | return new TestRunner().AddTestsFrom(); 56 | } 57 | 58 | /// 59 | /// Adds top level test methods. 60 | /// 61 | /// The used to execute the tests. 62 | public static TestRunner AddTopLevelTests() 63 | { 64 | return new TestRunner().AddTopLevelTests(); 65 | } 66 | 67 | /// 68 | /// A test runner capable of running unit tests. 69 | /// 70 | public class TestRunner 71 | { 72 | private readonly List _testFixtures; 73 | 74 | private readonly Action _formatter; 75 | 76 | private readonly Func _filter; 77 | 78 | private readonly Func _argumentProvider; 79 | 80 | private readonly Func _argumentsFormatter; 81 | 82 | private static object _submission; 83 | 84 | /// 85 | /// Initialize the runner with the submission instance to support top level test methods. 86 | /// 87 | /// 88 | internal static void Initialize(object submission) 89 | { 90 | _submission = submission; 91 | } 92 | 93 | /// 94 | /// Initializes a new instance of the class. 95 | /// 96 | public TestRunner() 97 | { 98 | _testFixtures = new List(); 99 | _formatter = summary => ProcessTestResult(summary); 100 | _filter = method => true; 101 | _argumentProvider = GetArguments; 102 | _argumentsFormatter = arguments => 103 | { 104 | var argumentList = arguments.Select(a => a.ToString()) 105 | .Aggregate((current, next) => $"{current}, {next}"); 106 | return $"({argumentList})"; 107 | }; 108 | } 109 | 110 | private TestRunner( 111 | List testFixtures, 112 | Action formatter, 113 | Func filter, 114 | Func argumentProvider, 115 | Func argumentsFormatter) 116 | { 117 | _testFixtures = testFixtures; 118 | _formatter = formatter; 119 | _filter = filter; 120 | _argumentProvider = argumentProvider; 121 | } 122 | 123 | /// 124 | /// Executes the tests that has been added to this . 125 | /// 126 | /// 0 if the test run succeeds, otherwise 1 127 | public async Task Execute() 128 | { 129 | List testResults = new List(); 130 | Stopwatch stopwatch = Stopwatch.StartNew(); 131 | try 132 | { 133 | foreach (var fixtureType in _testFixtures) 134 | { 135 | testResults.AddRange(await ExecuteTestMethods(fixtureType)); 136 | } 137 | } 138 | finally 139 | { 140 | stopwatch.Stop(); 141 | } 142 | 143 | _formatter(new Summary(stopwatch.Elapsed, testResults.ToArray())); 144 | return testResults.SelectMany(r => r.TestCaseResults).Any(tcr => tcr.Exception != null) ? 1 : 0; 145 | } 146 | 147 | /// 148 | /// Executes test fixtures (test classes) in parallel. 149 | /// 150 | /// 0 if the test run succeeds, otherwise 1 151 | public async Task ExecuteInParallel() 152 | { 153 | List testResults = new List(); 154 | Stopwatch stopwatch = Stopwatch.StartNew(); 155 | List> tasks = new List>(); 156 | try 157 | { 158 | foreach (var fixtureType in _testFixtures) 159 | { 160 | tasks.Add(ExecuteTestMethods(fixtureType)); 161 | } 162 | 163 | var allResults = await Task.WhenAll(tasks); 164 | foreach (var result in allResults) 165 | { 166 | testResults.AddRange(result); 167 | } 168 | } 169 | finally 170 | { 171 | stopwatch.Stop(); 172 | } 173 | 174 | _formatter(new Summary(stopwatch.Elapsed, testResults.ToArray())); 175 | return testResults.SelectMany(r => r.TestCaseResults).Any(tcr => tcr.Exception != null) ? 1 : 0; 176 | } 177 | 178 | /// 179 | /// Adds top level test methods. 180 | /// 181 | /// The used to execute the tests. 182 | public TestRunner AddTopLevelTests() 183 | { 184 | return AddTestsFrom(_submission.GetType()); 185 | } 186 | 187 | /// 188 | /// Adds tests from the given type. 189 | /// 190 | /// The type for which to add tests. 191 | /// The used to execute the tests. 192 | public TestRunner AddTestsFrom() 193 | { 194 | return AddTestsFrom(typeof(TFixture)); 195 | } 196 | 197 | public TestRunner AddTestsFrom(Type fixtureType) 198 | { 199 | var testFixtures = new List(new[] { fixtureType }); 200 | testFixtures.AddRange(_testFixtures); 201 | return new TestRunner(testFixtures, _formatter, _filter, _argumentProvider, _argumentsFormatter); 202 | } 203 | 204 | 205 | /// 206 | /// Returns a new with a new 207 | /// that is used to process the representing the result of the test run. 208 | /// 209 | /// The new summary formatter to be used for this . 210 | /// A new with the new . 211 | public TestRunner WithSummaryFormatter(Action summaryFormatter) 212 | { 213 | return new TestRunner(_testFixtures, summaryFormatter, _filter, _argumentProvider, _argumentsFormatter); 214 | } 215 | 216 | /// 217 | /// Returns a new with an additional . 218 | /// The new filter is "anded" with the previous filter. 219 | /// 220 | /// A function delegate used to determine if the target method is a test method. 221 | /// A new with the additional . 222 | public TestRunner AddFilter(Func methodFilter) 223 | { 224 | Func newFilter = method => _filter(method) && methodFilter(method); 225 | return new TestRunner(_testFixtures, _formatter, newFilter, _argumentProvider, _argumentsFormatter); 226 | } 227 | 228 | /// 229 | /// Returns a new that filters test methods based on a single . 230 | /// This is useful for executing a single test when debugging. 231 | /// 232 | /// The type of fixture for which to select a single test method. 233 | /// The expression used to select a test method from the given test fixture. 234 | /// A new with a method filter that selects a single method using the . 235 | public TestRunner AddFilter(Expression> methodSelector) 236 | { 237 | if (methodSelector.Body is MethodCallExpression testMethod) 238 | { 239 | return AddFilter(targetTestMethod => targetTestMethod == testMethod.Method); 240 | } 241 | 242 | throw new ArgumentOutOfRangeException(nameof(methodSelector), "Must be a method"); 243 | } 244 | 245 | /// 246 | /// Returns a new with a new 247 | /// that is used to provide the arguments for data driven tests. 248 | /// The default here is to get these values from the . 249 | /// 250 | /// The new argument provider for this . 251 | /// A new that uses the given to provide arguments to data driven tests. 252 | public TestRunner WithArgumentProvider(Func argumentProvider) 253 | { 254 | return new TestRunner(_testFixtures, _formatter, _filter, argumentProvider, _argumentsFormatter); 255 | } 256 | 257 | /// 258 | /// Returns a new with a new 259 | /// that is used to format test method arguments in the test result summary. 260 | /// 261 | /// The new arguments formatter for this . 262 | /// A new that used the given to 263 | /// format test method arguments in the test result summary. 264 | public TestRunner WithArgumentsFormatter(Func argumentsFormatter) 265 | { 266 | return new TestRunner(_testFixtures, _formatter, _filter, _argumentProvider, argumentsFormatter); 267 | } 268 | 269 | private static object[][] GetArguments(MethodInfo testMethod) 270 | { 271 | var argumentsAttributes = testMethod.GetCustomAttributes(inherit: true).ToArray(); 272 | if (argumentsAttributes.Length == 0) 273 | { 274 | return Array.Empty(); 275 | } 276 | 277 | List argumentsLists = new List(); 278 | foreach (var argumentsAttribute in argumentsAttributes) 279 | { 280 | argumentsLists.Add(argumentsAttribute.Arguments); 281 | } 282 | 283 | return argumentsLists.ToArray(); 284 | } 285 | 286 | private static async Task ExecuteTestMethod(object fixture, TestMethod testMethod) 287 | { 288 | var testCaseResults = new List(); 289 | var stopwatch = Stopwatch.StartNew(); 290 | 291 | foreach (var testCase in testMethod.TestCases) 292 | { 293 | testCaseResults.Add(await ExecuteTestCase(fixture, testMethod, testCase)); 294 | } 295 | 296 | stopwatch.Stop(); 297 | 298 | return new TestMethodResult( 299 | fixture.GetType().Name, 300 | testMethod.Method.Name, 301 | stopwatch.Elapsed, 302 | testCaseResults.ToArray()); 303 | } 304 | 305 | private static async Task ExecuteTestCase(object fixture, TestMethod testMethod, TestCase testCase) 306 | { 307 | Exception exception = null; 308 | var stopwatch = Stopwatch.StartNew(); 309 | RedirectedConsole.Clear(); 310 | try 311 | { 312 | if (testMethod.Method.ReturnType == typeof(Task)) 313 | { 314 | await (Task)testMethod.Method.Invoke(fixture, testCase.Arguments); 315 | } 316 | else 317 | { 318 | testMethod.Method.Invoke(fixture, testCase.Arguments); 319 | } 320 | } 321 | catch (TargetInvocationException targetInvocationException) 322 | { 323 | exception = targetInvocationException.InnerException; 324 | } 325 | catch (Exception ex) 326 | { 327 | exception = ex; 328 | } 329 | finally 330 | { 331 | stopwatch.Stop(); 332 | } 333 | 334 | return new TestCaseResult( 335 | stopwatch.Elapsed, 336 | RedirectedConsole.CurrentStandardOut, 337 | RedirectedConsole.CurrentStandardError, 338 | testCase.Arguments, 339 | exception); 340 | } 341 | 342 | private async Task ExecuteTestMethods(Type fixtureType) 343 | { 344 | var testResults = new List(); 345 | RedirectedConsole.Capture(); 346 | 347 | object instance; 348 | 349 | if (fixtureType.Name.Contains("Submission")) 350 | { 351 | instance = _submission; 352 | } 353 | else 354 | { 355 | instance = Activator.CreateInstance(fixtureType); 356 | } 357 | 358 | try 359 | { 360 | var testMethods = GetTestMethods(fixtureType).Where(tm => _filter(tm.Method)); 361 | foreach (var testMethod in testMethods) 362 | { 363 | testResults.Add(await ExecuteTestMethod(instance, testMethod)); 364 | } 365 | } 366 | finally 367 | { 368 | RedirectedConsole.Release(); 369 | CallDisposeMethod(instance); 370 | } 371 | 372 | return testResults.ToArray(); 373 | 374 | void CallDisposeMethod(object fixture) 375 | { 376 | if (fixture is IDisposable disposable) 377 | { 378 | disposable.Dispose(); 379 | } 380 | } 381 | } 382 | 383 | private TestMethod[] GetTestMethods(Type fixtureType) 384 | { 385 | var exceptTheseMethods = new List(); 386 | if (typeof(IDisposable).IsAssignableFrom(fixtureType)) 387 | { 388 | var disposableMap = fixtureType.GetInterfaceMap(typeof(IDisposable)); 389 | exceptTheseMethods.AddRange(disposableMap.TargetMethods); 390 | } 391 | 392 | var targetTestMethods = fixtureType.GetMethods(BindingFlags.Public | BindingFlags.Instance) 393 | .Where(m => m.DeclaringType != typeof(object) && !exceptTheseMethods.Any(tm => tm == m)); 394 | 395 | var testMethods = new List(); 396 | foreach (var targetTestMethod in targetTestMethods) 397 | { 398 | var argumentSets = _argumentProvider(targetTestMethod).ToList(); 399 | if (argumentSets.Count == 0) 400 | { 401 | argumentSets.Add(Array.Empty()); 402 | } 403 | 404 | var testCases = argumentSets.Select(arguments => new TestCase(arguments)); 405 | testMethods.Add(new TestMethod(targetTestMethod, testCases.ToArray())); 406 | } 407 | 408 | return testMethods.ToArray(); 409 | } 410 | 411 | private void ProcessTestResult(Summary summary) 412 | { 413 | var results = summary.TestResults; 414 | if (results.Length == 0) 415 | { 416 | return; 417 | } 418 | 419 | var allTestCases = results.SelectMany(r => r.TestCaseResults).ToArray(); 420 | var totalCount = allTestCases.Length; 421 | var failedCount = allTestCases.Count(r => r.Exception != null); 422 | var passedCount = totalCount - failedCount; 423 | var totalElapsed = summary.TotalDuration; 424 | var maxWidth = results.Select(r => $"{r.Fixture}.{r.Name}".Length).OrderBy(l => l).Last() + 5; 425 | 426 | foreach (var testResult in results) 427 | { 428 | var name = $"{testResult.Fixture}.{testResult.Name}"; 429 | Console.WriteLine($"{name.PadRight(maxWidth, ' ')} {(int)testResult.Duration.TotalMilliseconds}ms"); 430 | 431 | if (testResult.TestCaseResults.Length > 1 && testResult.TestCaseResults[0].Arguments.Length > 0) 432 | { 433 | foreach (var testCase in testResult.TestCaseResults) 434 | { 435 | FormatTestCase(testResult, testCase); 436 | } 437 | } 438 | else 439 | { 440 | WriteOutput(testResult.TestCaseResults.Single()); 441 | } 442 | } 443 | 444 | Console.WriteLine($"Total tests: {totalCount}. Passed: {passedCount}. Failed: {failedCount}."); 445 | if (failedCount > 0) 446 | { 447 | WriteFailed("Test Run Failed."); 448 | } 449 | else 450 | { 451 | WriteSuccessful("Test Run Successful."); 452 | } 453 | 454 | Console.WriteLine($"Test execution time {totalElapsed.TotalSeconds} seconds"); 455 | 456 | void FormatTestCase(TestMethodResult testMethodResult, TestCaseResult testCase) 457 | { 458 | if (testCase.Arguments.Length > 0) 459 | { 460 | var argumentString = _argumentsFormatter(testCase.Arguments); 461 | var name = $"* {testMethodResult.Fixture}.{testMethodResult.Name}{argumentString}"; 462 | Console.WriteLine(name); 463 | Console.WriteLine($"{string.Empty.PadRight(maxWidth, ' ')} {(int)testCase.Duration.TotalMilliseconds}ms"); 464 | } 465 | else 466 | { 467 | var name = $"{testMethodResult.Fixture}.{testMethodResult.Name}"; 468 | Console.WriteLine($"{name.PadRight(maxWidth, ' ')} {(int)testCase.Duration.TotalMilliseconds}ms"); 469 | } 470 | 471 | WriteOutput(testCase); 472 | } 473 | 474 | void WriteOutput(TestCaseResult testCase) 475 | { 476 | if (!string.IsNullOrWhiteSpace(testCase.StandardOut)) 477 | { 478 | Console.WriteLine("Standard Out"); 479 | Console.WriteLine(testCase.StandardOut); 480 | } 481 | 482 | if (!string.IsNullOrWhiteSpace(testCase.StandardError)) 483 | { 484 | Console.WriteLine("Standard Error"); 485 | Console.WriteLine(testCase.StandardError); 486 | } 487 | 488 | if (testCase.Exception != null) 489 | { 490 | Console.WriteLine("Exception"); 491 | WriteFailed(testCase.Exception.ToString()); 492 | } 493 | } 494 | 495 | void WriteSuccessful(string value) 496 | { 497 | var oldColor = Console.ForegroundColor; 498 | Console.ForegroundColor = ConsoleColor.Green; 499 | Console.WriteLine(value); 500 | Console.ForegroundColor = oldColor; 501 | } 502 | 503 | void WriteFailed(string value) 504 | { 505 | var oldColor = Console.ForegroundColor; 506 | Console.ForegroundColor = ConsoleColor.Red; 507 | Console.WriteLine(value); 508 | Console.ForegroundColor = oldColor; 509 | } 510 | } 511 | 512 | internal static class RedirectedConsole 513 | { 514 | private static AsyncTextWriter _standardOutputWriter; 515 | private static AsyncTextWriter _standardErrorWriter; 516 | private static TextWriter _oldStandardOutWriter; 517 | private static TextWriter _oldStandardErrorWriter; 518 | 519 | private static int _captureCount; 520 | 521 | public static string CurrentStandardOut => _standardOutputWriter.CurrentValue; 522 | 523 | public static string CurrentStandardError => _standardErrorWriter.CurrentValue; 524 | 525 | public static void Capture() 526 | { 527 | if (_captureCount > 0) 528 | { 529 | _captureCount++; 530 | return; 531 | } 532 | 533 | _standardOutputWriter = new AsyncTextWriter(); 534 | _standardErrorWriter = new AsyncTextWriter(); 535 | _oldStandardOutWriter = Console.Out; 536 | _oldStandardErrorWriter = Console.Error; 537 | Console.SetOut(_standardOutputWriter); 538 | Console.SetError(_standardErrorWriter); 539 | _captureCount++; 540 | } 541 | 542 | public static void Release() 543 | { 544 | _captureCount--; 545 | if (_captureCount == 0) 546 | { 547 | Console.SetOut(_oldStandardOutWriter); 548 | Console.SetError(_oldStandardErrorWriter); 549 | } 550 | } 551 | 552 | public static void Clear() 553 | { 554 | _standardOutputWriter.Clear(); 555 | _standardErrorWriter.Clear(); 556 | } 557 | } 558 | } 559 | 560 | /// 561 | /// Represents a test case. 562 | /// 563 | public class TestCase 564 | { 565 | /// 566 | /// Initializes a new instance of the class. 567 | /// 568 | /// A list of values to be passed as arguments to the target test method. 569 | public TestCase(object[] arguments) 570 | { 571 | Arguments = arguments; 572 | } 573 | 574 | /// 575 | /// Gets a list of values to be passed as arguments to the target test method. 576 | /// 577 | public object[] Arguments { get; } 578 | } 579 | 580 | /// 581 | /// Represents a test method. 582 | /// 583 | public class TestMethod 584 | { 585 | /// 586 | /// Initializes a new instance of the class. 587 | /// 588 | /// The representing the test method. 589 | /// A list of test cases to be executed as part of this test method. 590 | public TestMethod(MethodInfo testMethod, TestCase[] testCases) 591 | { 592 | Method = testMethod; 593 | TestCases = testCases; 594 | } 595 | 596 | /// 597 | /// Gets the representing the test method. 598 | /// 599 | public MethodInfo Method { get; } 600 | 601 | /// 602 | /// Gets a list of test cases to be executed as part of this test method. 603 | /// 604 | public TestCase[] TestCases { get; } 605 | } 606 | 607 | /// 608 | /// Represents the result of a test run. 609 | /// 610 | public class Summary 611 | { 612 | /// 613 | /// Initializes a new instance of the class. 614 | /// 615 | /// The total duration of the test run. 616 | /// A list of test method result that contains the result for each test method. 617 | public Summary(TimeSpan totalDuration, TestMethodResult[] testResults) 618 | { 619 | TotalDuration = totalDuration; 620 | TestResults = testResults; 621 | } 622 | 623 | /// 624 | /// Gets total duration of the test run. 625 | /// 626 | public TimeSpan TotalDuration { get; } 627 | 628 | /// 629 | /// Gets a list of test method result that contains the result for each test method. 630 | /// 631 | public TestMethodResult[] TestResults { get; } 632 | } 633 | 634 | /// 635 | /// Represents the result of executing a . 636 | /// 637 | public class TestMethodResult 638 | { 639 | /// 640 | /// Initializes a new instance of the class. 641 | /// 642 | /// The name of the test fixture (test class type). 643 | /// The name of the test method. 644 | /// The duration of the test method execution. 645 | /// A list of test cases executed as part of this test method. . 646 | public TestMethodResult(string fixture, string name, TimeSpan duration, TestCaseResult[] testCaseResults) 647 | { 648 | Fixture = fixture; 649 | Name = name; 650 | Duration = duration; 651 | TestCaseResults = testCaseResults; 652 | } 653 | 654 | /// 655 | /// Gets the name of the test fixture (test class type). 656 | /// 657 | public string Fixture { get; } 658 | 659 | /// 660 | /// Gets the name of the test method. 661 | /// 662 | public string Name { get; } 663 | 664 | /// 665 | /// Gets the duration of the test method execution. 666 | /// 667 | public TimeSpan Duration { get; } 668 | 669 | /// 670 | /// Gets a list of test cases executed as part of this test method. . 671 | /// 672 | public TestCaseResult[] TestCaseResults { get; } 673 | } 674 | 675 | /// 676 | /// Represents the result of executing a . 677 | /// 678 | public class TestCaseResult 679 | { 680 | /// 681 | /// Initializes a new instance of the class. 682 | /// 683 | /// The duration of the test case execution. 684 | /// The standard output captured during test case execution. 685 | /// The standard error captured during test case execution. 686 | /// The arguments passed to the target test method. 687 | /// The caught, if any, during test case execution. 688 | public TestCaseResult( 689 | TimeSpan duration, 690 | string standardOut, 691 | string standardError, 692 | object[] arguments, 693 | Exception exception = null) 694 | { 695 | Duration = duration; 696 | StandardOut = standardOut; 697 | StandardError = standardError; 698 | Arguments = arguments; 699 | Exception = exception; 700 | } 701 | 702 | /// 703 | /// Gets the duration of the test case execution. 704 | /// 705 | public TimeSpan Duration { get; } 706 | 707 | /// 708 | /// Gets the standard output captured during test case execution. 709 | /// 710 | public string StandardOut { get; } 711 | 712 | /// 713 | /// Gets the standard error captured during test case execution. 714 | /// 715 | public string StandardError { get; } 716 | 717 | /// 718 | /// Gets the arguments passed to the target test method. 719 | /// 720 | public object[] Arguments { get; } 721 | 722 | /// 723 | /// Gets the caught, if any, during test case execution. 724 | /// 725 | public Exception Exception { get; } 726 | } 727 | 728 | /// 729 | /// A that writes to the current logical thread context. 730 | /// 731 | public class AsyncTextWriter : TextWriter 732 | { 733 | private readonly AsyncLocal _output = new AsyncLocal(); 734 | 735 | /// 736 | /// Initializes a new instance of the class. 737 | /// 738 | public AsyncTextWriter() 739 | { 740 | Clear(); 741 | } 742 | 743 | /// 744 | /// Gets the current value from the logical thread context. 745 | /// 746 | public string CurrentValue => _output.Value.ToString(); 747 | 748 | /// 749 | /// Gets the . 750 | /// 751 | public override Encoding Encoding { get; } = Encoding.Default; 752 | 753 | /// 754 | /// Write the value to the underlying . 755 | /// 756 | /// The value to write. 757 | public override void Write(char value) 758 | { 759 | _output.Value.Append(value); 760 | } 761 | 762 | /// 763 | /// Clears the current underlying . 764 | /// 765 | public void Clear() => _output.Value = new StringBuilder(); 766 | } 767 | 768 | /// 769 | /// Provides access to "standard out" and "standard error" from with a test method. 770 | /// 771 | public class TestContext 772 | { 773 | /// 774 | /// Gets the "standard out" for the currently executing test. 775 | /// 776 | public static string StandardOut => TestRunner.RedirectedConsole.CurrentStandardOut; 777 | 778 | /// 779 | /// Gets the "standard error" for the currently executing test. 780 | /// 781 | public static string StandardError => TestRunner.RedirectedConsole.CurrentStandardError; 782 | } 783 | 784 | /// 785 | /// Specifies the arguments to be used when executing the test method. 786 | /// 787 | [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] 788 | public class ArgumentsAttribute : Attribute 789 | { 790 | /// 791 | /// Initializes a new instance of the class. 792 | /// 793 | /// A list of values to be passed as arguments to the target test method. 794 | public ArgumentsAttribute(params object[] arguments) 795 | { 796 | Arguments = arguments; 797 | } 798 | 799 | /// 800 | /// Gets a list of values to be passed as arguments to the target test method. 801 | /// 802 | public object[] Arguments { get; } 803 | } 804 | 805 | public class DisposableFolder : IDisposable 806 | { 807 | public DisposableFolder() 808 | { 809 | var tempFolder = System.IO.Path.GetTempPath(); 810 | this.Path = System.IO.Path.Combine(tempFolder, System.IO.Path.GetFileNameWithoutExtension(System.IO.Path.GetTempFileName())); 811 | Directory.CreateDirectory(Path); 812 | } 813 | 814 | public string Path { get; } 815 | 816 | public void Dispose() 817 | { 818 | RemoveDirectory(Path); 819 | 820 | void RemoveDirectory(string path) 821 | { 822 | if (!Directory.Exists(path)) 823 | { 824 | return; 825 | } 826 | NormalizeAttributes(path); 827 | 828 | foreach (string directory in Directory.GetDirectories(path)) 829 | { 830 | RemoveDirectory(directory); 831 | } 832 | 833 | try 834 | { 835 | Directory.Delete(path, true); 836 | } 837 | catch (IOException) 838 | { 839 | Directory.Delete(path, true); 840 | } 841 | catch (UnauthorizedAccessException) 842 | { 843 | Directory.Delete(path, true); 844 | } 845 | 846 | void NormalizeAttributes(string directoryPath) 847 | { 848 | string[] filePaths = Directory.GetFiles(directoryPath); 849 | string[] subdirectoryPaths = Directory.GetDirectories(directoryPath); 850 | 851 | foreach (string filePath in filePaths) 852 | { 853 | File.SetAttributes(filePath, FileAttributes.Normal); 854 | } 855 | foreach (string subdirectoryPath in subdirectoryPaths) 856 | { 857 | NormalizeAttributes(subdirectoryPath); 858 | } 859 | File.SetAttributes(directoryPath, FileAttributes.Normal); 860 | } 861 | } 862 | } 863 | } 864 | } --------------------------------------------------------------------------------