Сразу хочу сказать, что NUnit используется мной при написании автоматических тестов с помощью C# + Selenium WebDriver, но думаю это не играет особой роли при создании инструмента генерации собственного отчёта. Отчёт формируется в формате HTML.

Вот пример отчёта, который генерируется (нажмите на картинку, для увеличения):

Как видим в отчёте имеется информация о том, когда начат тест, когда тест завершён, сколько тестов запущено, сколько выполнено успешно, сколько провалено, каков процент прохождения тестов. Также отчёт имеет ссылку на тест-кейс, на который написан автоматический тест, если тест провален, то есть ссылка на скриншот страницы, который сделан в момент возникновения ошибки. Если вы не используете тесты веба, то вам придётся доработать/изменить отчёт под себя.

Начнём рассматривать код, который нам потребуется для реализации отчёта.

Первым делом не забываем объявить все необходимые переменные.
Далее создадим метод, который будет формировать отчёт.
Создадим «обвязку» для тестов, чтобы формировался отчёт, и чтобы корректно фиксировался результат прохождения теста (успешный/проваленный).

Теперь приведу всю структуру всего работающего кода. Кстати, у меня тесты хранятся в EXE, поэтому структура немного отличается, но явно не запутает тех, кто пишет тесты в виде DLL.

using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading;
using System.Diagnostics;
using System.Collections.Generic;
using NUnit.Framework;
using Selenium;
using OpenQA.Selenium;
using OpenQA.Selenium.Interactions;
using OpenQA.Selenium.Remote;
using OpenQA.Selenium.Firefox;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.PhantomJS;
using OpenQA.Selenium.Support.UI;
using System.Drawing.Imaging;
using System.Reflection;
using System.Globalization;

namespace TESTING_SPACE
{
	[TestFixture]
	class Test
	{
		public static void Main(string[] args)
		{
			// это обязательно для корректной работы EXE
			string path = Assembly.GetExecutingAssembly().Location; // требуется "using System.Reflection;"
			NUnit.ConsoleRunner.Program.Main(new[] { path });
		}

		public static IWebDriver driver;
		public static string iTestNumCurrent = ""; // текущий номер выполняемого теста (для самоформируемого отчёта)
		public static bool iExecTestGood = false; // переменная для проверки успешности проходимого теста (в рамках самоформируемого отчёта)
		public static string iWorkDir = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); // рабочий каталог, относительно исполняемого файла (относительно EXE или DLL, в зависимости от того в чём содержатся тесты
		public static string iFolderResultTest = iWorkDir + @"\Report"; // каталог результатов выполнения тестов
		public static string iFolderScreen = iFolderResultTest + @"\screen"; // каталог со скринами
		public static string iPathReportFile = iFolderResultTest + @"\Report.html"; // путь к файлу отчёта о тестировании
		public static int iTestCountGood = 0; // количество успешно пройденных тестов (используем в GenerateReport)
		public static int iTestCountFail = 0; // количество не пройденных тестов (используем в GenerateReport)

		[OneTimeSetUp] // вызывается перед началом запуска всех тестов
		public void TestFixtureSetUp()
		{
			// КОД... в котором надо удалить старый каталог с отчётом и создать новый, если он один и тот же (не привожу этот код, напишите сами)
			GenerateReport(0, "0", true); // генерируем начало отчёта
			// ниже инициализация драйвера Chrome, если не используете WebDriver для тестов, то вам это не надо
			ChromeOptions options = new ChromeOptions();
			options.AddArguments("--ignore-certificate-errors");
			options.AddArguments("--ignore-ssl-errors");
			driver = new ChromeDriver(iWorkDir, options); // сам драйвер хрома находится в каталоге с тестами - iWorkDir
			driver.Manage().Window.Maximize();
		}

		[OneTimeTearDown] //вызывается после завершения всех тестов
		public void TestFixtureTearDown()
		{
			GenerateReport(9, "0", true); // генерируем конец отчёта
			driver.Quit();
		}

		[SetUp] // вызывается перед каждым тестом
		public void SetUp()
		{
			// ТУТ КОД
		}

		[TearDown] // вызывается после каждого теста
		public void TearDown()
		{
			// ТУТ КОД
		}

		[Test]
		public void TEST_1() // Сам тест
		{
			iTestNumCurrent = "WSP-1251"; // номер теста
			try
			{
				// КОД ТЕСТА - НАЧАЛО
				int i = 2 + 2;
				Assert.True(i > 2);
				// КОД ТЕСТА - КОНЕЦ
				GenerateReport(1, iTestNumCurrent, true); // в отчёт, если успешно пройден тест
				iExecTestGood = true; // результат выполнения теста
			}
			catch (Exception ex)
			{
				GenerateReport(1, iTestNumCurrent, false, ex.ToString()); // в отчёт, если не пройден тест
				iExecTestGood = false; // результат выполнения теста
			}
			Assert.True(iExecTestGood); // проверить результат выполнения теста, здесь это нужно для того чтобы в студии, в которой пишем тесты мы корректно видели, что тест прошёл/не прошёл, иначе все тесты пройдут успешно так как мы перехватываем ошибки для отчёта с помощью try-catch
		}


/// <summary>
/// Метод генерации отчёта о тестировании.
/// "iStep" - этап внесения информации: 0 - начало отчёта, 1 - составление отчёта, 9 - закрытие отчёта
/// "iTestNum" - номер теста, пример: WSP-1278
/// "iResult" - результат выполнения теста: true/false
/// "iMessage" - заносимая в отчёт информация (сообщение об ошибке)
/// </summary>
public static void GenerateReport(int iStep, string iTestNum, bool iResult, string iMessage = "-")
		{
			iMessage = iMessage.Replace("<","&lt;").Replace(">", "&gt;"); // замена угловых кавычек в тегах выводимых в ошибках, чтобы вёрстка отчёта не "ехала"
			iMessage = iMessage.Replace("\n", "</br>"); // замена, для вставки HTML переносов строк
			string iTime = String.Format("{0:HH:mm:ss}", DateTime.Now); // время фиксирования данных в отчёте (вторая колонка отчёта)
			// далее для записи данных в файл
			FileStream fs = new FileStream(iPathReportFile, FileMode.Append, FileAccess.Write);
			StreamWriter sw = new StreamWriter(fs);
			// далее формирование отчёта
			if (iStep == 0) // начало отчёта
			{
				iTestCountGood = 0; // количество успешно пройденных тестов, в начале формирования отчёта сбрасываем в ноль
				iTestCountFail = 0; // количество не пройденных тестов, в начале формирования отчёта сбрасываем в ноль
				sw.WriteLine(@"<!DOCTYPE html>" + "\n" +
					@"<html lang='ru-RU'>" + "\n" +
					@"<head>" + "\n" +
					@"<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />" + "\n" +
					@"<title>Отчёт о тестировании</title>" + "\n" +
					@"</head>" + "\n" +
					@"<body>" + "\n" +
					@"<div style='font-size:22px;' align='center'><strong>Тест начат: " + String.Format("{0:dd.MM.yyyy HH:mm:ss}", DateTime.Now) + @"</strong></div></br>" + "\n" +
					@"<table border='1' align='center' cellpadding='5' cellspacing='0' width='100%'>" + "\n" +
					@"<tr style='text-align:center;'>" + "\n" +
					@"<td width='100px'><strong>Тест</strong></td>" + "\n" +
					@"<td width='70px'><strong>Результат</strong></td>" + "\n" +
					@"<td width='70px'><strong>Время</strong></td>" + "\n" +
					@"<td width='70px'><strong>Снимок</strong></td>" + "\n" +
					@"<td><strong>Сообщение</strong></td>" + "\n" +
					@"</tr>");
			}
			if (iResult == true & iStep == 1) // запись о положительном тесте
			{
				iTestCountGood = iTestCountGood + 1;
				sw.WriteLine(@"<tr style='color: green;'>" + "\n" +
					@"<td><a target='_blank' href='http://jira.ru/browse/" + iTestNum + "'>" + iTestNum + @"</a></td>" + "\n" +
					@"<td>Успех</td>" + "\n" +
					@"<td>" + iTime + @"</td>" + "\n" +
					@"<td>-</td>" + "\n" +
					@"<td>" + iMessage + @"</td>" + "\n" +
					@"</tr>" + "\n");
			}
			if (iResult == false & iStep == 1) // запись о отрицательном тесте
			{
				iTestCountFail = iTestCountFail + 1;
				ITakesScreenshot screenshotDriver = Test.driver as ITakesScreenshot; // сделать скриншот
				Screenshot screenshot = screenshotDriver.GetScreenshot();
				string iNameScreen = iTestNum + "_" + String.Format("{0:yyyy-MM-dd_HH-mm-ss}", DateTime.Now) + ".png"; // префикс имени файла
				screenshot.SaveAsFile(iFolderScreen + @"\" + iNameScreen, ImageFormat.Png);
				sw.WriteLine(@"<tr style='color: red;'>" + "\n" + // создать отчёт
					@"<td><a target='_blank' href='http://jira.ru/browse/" + iTestNum + "'>" + iTestNum + @"</a></td>" + "\n" +
					@"<td>Провал</td>" + "\n" +
					@"<td>" + iTime + @"</td>" + "\n" +
					@"<td><a target='_blank' href='screen/" + iNameScreen + "'>смотреть</a></td>" + "\n" +
					@"<td>" + iMessage + @"</td>" + "\n" +
					@"</tr>" + "\n");
			}
			if (iStep == 9) // конец отчёта
			{
				Decimal iProcent = 0;
				if (igTestCountGood > 0 || igTestCountFail > 0)
				{
					iProcent = ((100 * igTestCountGood) / (igTestCountGood + igTestCountFail)); // процент пройденных тестов, далее через Math.Round округлим до десятых
				}
				sw.WriteLine(@"<tr style='text-align:center;'>" + "\n" +
					@"<td colspan='5'>&nbsp;</center></td>" + "\n" +
					@"</tr>" + "\n" +
					@"<tr style='text-align:center;'>" + "\n" +
					@"<td colspan='5'>Всего тестов запущено: " + (iTestCountGood + iTestCountFail) + " || <span style='color: green;'>Успешно: " + iTestCountGood +
							 "</span> || <span style='color: red;'>Провалено: " + iTestCountFail + "</span> || Процент пройденных: " + iProcent + "% || Тест завершён: " + String.Format("{0:dd.MM.yyyy HH:mm:ss}", DateTime.Now) + "</td>" + "\n" +
					@"</tr>" + "\n" +
					@"</table>" + "\n" +
					@"</body>" + "\n" +
					@"</html>");
			}
			sw.Close();
			// iPathReportFile - указывается полный путь до файла отчёта включая имя самого файла
		}

	}
}

Обратите внимание, что в первом столбце указывается номер проходимого теста. У нас он привязывается к номеру тест-кейса, которые мы создаём в специальном дополнении к JIRA. Поэтому мы стараемся автотест привязать к тест-кейсу и для этого:
— автотесты именуем как тест-кейсы;
— добавляем ссылку на тест-кейс в отчёте, чтобы с отчёта можно было быстро перейти в тест-кейс, для получения информации о проводимой проверке и исходной информации.

Один из минусов данного метода генерации отчёта — обвязка тестов, которая увеличивает количество строк кода:

iTestNumCurrent = "WSP-1251";
try
{
	// КОД ТЕСТА
	GenerateReport(1, iTestNumCurrent, true);
	iExecTestGood = true;
}
catch (Exception ex)
{
	GenerateReport(1, iTestNumCurrent, false, ex.ToString());
	iExecTestGood = false;
}
Assert.True(iExecTestGood);

В момент написания и отладки теста я не использую обвязку и только после того как тест отлаживается он помещается в обвязку.

Надо было придумать быстрое решение для корректной визуализации пройденных тестов, поэтому и была придумана генерация отчёта в процессе прохождения тестов.

Возможно позже придумаем парсер стандартных отчётов и будем их парсить не используя обвязки тестов и генераторы отчётов. Возможно интегрировав тесты в систему непрерывной интеграции, мы будем использовать возможности этих систем, тем более судя по отзывам они могут парсить отчёты NUnit, но пока тесты у нас не интегрированы.

Возможно данное решение может кому-нибудь пригодиться.

Обновлено 02.08.2017: система непрерывной интеграции TeamCity не генерирует понятные отчёты, поэтому на данный момент оставили наш самогенерирующийся отчёт, так как он удобен.
Для тех кто использует Selenium WebDriver: в процессе улучшения отчёта я ещё добавил в столбец «Скриншот» вывод URL страницы, на которой тест провалился, для этого надо расширить код там где отображается в отчёте ссылка на скриншот:

<a target='_blank' href='screen/" + iNameScreen + "'>смотреть</a>

заменить на:

<center><a target='_blank' href='screen/" + iNameScreen + @"'>скриншот</a><br/><br/><a href='" + driver.Url + @"' target='_blank'>URL</a></center>