GrabDuck

Unit тесты на практике

:

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

Сразу оговорюсь, что примеры приводятся применительно к языку C# и платформе .NET. Соответственно, в других языках/платформах подходы и реализации могут отличаться.

Итак…

Какими должны быть модульные тесты?

Помимо того, что модульные тесты должны соответствовать функциональности программного продукта, основное требование — скорость работы. Если после запуска набора тестов разработчик может сделать перерыв (в моём случае на перекур), то подобные запуски будут происходить всё реже и реже (опять же, в моём случае, из-за опасения получить передозировку никотином). В результате может получиться так, что модульные тесты вообще не будут запускаться и, как следствие, потеряется смысл их написания. Программист должен иметь возможность запустить весь набор тестов в любой момент времени. И этот набор должен выполниться настолько быстро, насколько это возможно.

Какие требования необходимо соблюсти для того, чтобы обеспечить скорость выполнения модульных тестов?

Тесты должны быть небольшими

В идеальном случае — одно утверждение (assert) на тест. Чем меньше кусочек функциональности, покрываемый модульным тестом, тем быстрее тест будет выполняться.

Кстати, на тему оформления. Мне очень нравится подход, который формулируется как «arrange-act-assert».
Суть его заключается в том, чтобы в модульном тесте чётко определить предусловия (инициализация тестовых данных,
предварительные установки), действие (собственно то, что тестируется) и постусловия (что должно быть в
результате выполнения действия). Подобное оформление повышает читаемость теста и облегчает его
использование в качестве документации к тестируемой функциональности.

Если в разработке используется ReSharper от JetBrains, то очень удобно настроить template, с помощью которого будет создаваться заготовка для тестового случая. Например, template может выглядеть вот так:

[Test]
public void Test_$METHOD_NAME$()
{
    //arrange
    $END$

    //act

    //assert
    Assert.Fail("Not implemented");
}

И тогда тест, оформленный подобным образом, может выглядеть примерно так (все имена вымышленные, совпадения случайны):

[Test]
public void Test_ForbiddenForPackageChunkWhenPackageNotFound()
{
    //arrange
    var packagesRepositoryMock = _mocks.Create<IPackagesRepository>();
    packagesRepositoryMock
        .Setup(r => r.FindPackageAsync(_packageId))
        .Returns(Task<DatabasePackage>.Factory.StartNew(() => null));
    Register(packagesRepositoryMock.Object);

    //act
    var message = PostChunkToServer(new byte[] { 1, 2, 3 });

    //assert
    _mocks.VerifyAll();

    Assert.That(message.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
}

Тесты должны быть изолированы от окружения (БД, сеть, файловая система)

Этот пункт, наверное, самый спорный из всех. Зачастую в литературе рассматриваются примеры использования TDD на таких задачах как разработка калькулятора или телефонного справочника. Понятно, что эти задачи оторваны от реальности и разработчик, возвращаясь в свой проект, не знает, как применить полученные знания и навыки в повседневной работе. Простейшие случаи, похожие на пресловутый калькулятор, покрываются модульными тестами, а остальная часть функциональности разрабатывается на прежних рельсах. В итоге пропадает понимание того, зачем тратить время на модульные тесты, если простой код можно и так отладить, а сложный всё равно тестами не покрывается.

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

Случай 1. Слой доступа к данным (MS SQL Server)

Если в разработке проекта используется MS SQL сервер, то ответом на этот вопрос может быть использование установленного экземпляра MS SQL сервер (Express, Enterprise или Developer Edition) для разворачивания тестовой базы данных. Подобную базу данных можно создать с помощью стандартных механизмов, используемых в MS SQL Management Studio и поместить её в проект с модульными тестами. Общий подход к использованию такой базы данных заключается в разворачивании тестовой БД перед выполнением теста (например, в методе, отмеченном атрибутом SetUp в случае использования NUnit), наполнении БД тестовыми данными и проверками функциональности репозиториев или шлюзов на этих, заведомо известных, тестовых данных. Причём разворачиваться тестовая БД может как на жёстком диске, так и в памяти, используя приложения, создающие и управляющие RAM диском. Допустим, в проекте, над которым я работаю в данное время, используется приложение SoftPerfect RAM Disk. Использование RAM диска в модульных тестах позволяет снизить задержки, возникающие при операциях ввода/вывода, которые возникали бы при разворачивании тестовой БД на жёстком диске. Конечно, данный подход не идеален, так как требует внесения в окружение разработчика стороннего ПО. С другой стороны, если учесть, что среда для разработки разворачивается, как правило, один раз (ну, или достаточно редко), то это требование не кажется таким уж обременяющим. Да и выигрыш от использования такого подхода достаточно заманчив, ведь появляется возможность контролировать корректность работы одного из важнейших слоёв системы.

Кстати, если есть возможность использовать в модульных тестах LINQ2SQL и SMO для MS SQL Server, то можно воспользоваться следующим базовым классом для тестирования слоя доступа к данным:

Код
public abstract class DatabaseUnitTest<TContext> where TContext : DataContext
{
    [TestFixtureSetUp]
    public void FixtureSetUp()
    {
        CreateFolderForTempDatabase();
    }

    [SetUp]
    public void BeforeTestExecuting()
    {
        RestoreDatabaseFromOriginal();
        RecreateContext();		
    }

    [TestFixtureTearDown]
    public void FixtureTearDown()
    {
        KillDatabase();
    }

    protected string ConnectionString
    {
        get
        {
            return String.Format(@"Data Source={0};Initial Catalog={1};Integrated Security=True", 
                    TestServerName, TestDatabaseName);
        }
    }

    protected TContext Context { get; private set; }

    protected string TestDatabaseOriginalName { get { return "Database"; } }

    protected string ProjectName { get { return "CoolProject"; } }

    protected void RecreateContext()
    {
        Context = (TContext) Activator.CreateInstance(typeof(TContext), ConnectionString);
    }

    private string FolderForTempDatabase
    {
        get { 	return String.Format(@"R:\{0}.DatabaseTests\", ProjectName); }
    }

    private string TestDatabaseName
    {
        get { 	return FolderForTempDatabase + ProjectName + ".Tests"; }
    }

    private string TestDatabaseOriginalFileName
    {
        get {	return Path.Combine(TestDatabaseDirectory, TestDatabaseOriginalName + ".mdf"); }
    }

    private string TestDatabaseFileName
    {
        get { 	return Path.Combine(TestDatabaseDirectory, TestDatabaseName + ".mdf"); }
    }

    private void CreateFolderForTempDatabase()
    {
        var directory = new DirectoryInfo(FolderForTempDatabase);
        if(!directory.Exists)
        {
            directory.Create();
        }
    }

    private void RestoreDatabaseFromOriginal()
    {
        KillDatabase();
        CopyFiles();
        AttachDatabase();
    }

    private void KillDatabase()
    {
        Server server = Server;
        SqlConnection.ClearAllPools();
        if(server.Databases.Contains(TestDatabaseName))
        {
            server.KillDatabase(TestDatabaseName);
        }
    }

    private void CopyFiles()
    {
        new FileInfo(TestDatabaseOriginalFileName).CopyTo(TestDatabaseFileName, true);
        string logFileName = GetLogFileName(TestDatabaseFileName);
        new FileInfo(GetLogFileName(TestDatabaseOriginalFileName)).CopyTo(logFileName, true);
        new FileInfo(TestDatabaseFileName).Attributes = FileAttributes.Normal;
        new FileInfo(logFileName).Attributes = FileAttributes.Normal;
    }

    private void AttachDatabase()
    {
        Server server = Server;
        if(!server.Databases.Contains(TestDatabaseName))
        {
            server.AttachDatabase(TestDatabaseName, new StringCollection {TestDatabaseFileName, GetLogFileName(TestDatabaseFileName)});
        }			
    }

    private static string GetLogFileName(string databaseFileName)
    {
        return new Regex(".mdf$", RegexOptions.IgnoreCase).Replace(databaseFileName, "_log.ldf");
    }

    private static Server Server { get 	{ return new Server(TestServerName); } }

    private static string TestServerName { get { return "."; } 	}

    private static string TestDatabaseDirectory
    {
        get
        {
            var debugDirectory = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
            DirectoryInfo binDirectory = debugDirectory.Parent;
            DirectoryInfo testProjectDirectory;
            if(binDirectory == null || (testProjectDirectory = binDirectory.Parent) == null)
            {
                throw new Exception("");
            }
            return Path.Combine(testProjectDirectory.FullName, "Database");
        }
    }	
}


После использования которого тесты на взаимодействие с БД будут выглядеть примерно так:
[TestFixture]
public class ObjectFinderTest : DatabaseUnitTest<DatabaseDataContext>
{
    [Test]
    public void Test_NullWhenObjectNotExists()
    {
        //arrange
        var fakeIdentifier = 0;
        var finder = new ObjectFinder(fakeIdentifier, ConnectionString);

        //act
        var foundObject = finder.Find();

        //assert
        Assert.That(foundObject, Is.Null);
    }

    [Test]
    public void Test_SuccessfullyFound()
    {
        //arrange
        var insertedObject = ObjectsFactory.Create();
        Context.Objects.InsertOnSubmit(insertedObject);
        Context.SubmitChanges();

        var finder = new ObjectFinder(insertedObject.Id, ConnectionString);

        //act
        var foundObject = finder.Find();

        //assert
        Assert.That(foundObject.Id, Is.EqualTo(insertedObject.Id));
        Assert.That(foundObject.Property, Is.EqualTo(insertedObject.Property));
    }
}

Вуаля! Мы получили возможность тестирования слоя доступа к БД.

Случай 2. ASP.NET MVC WebAPI

При тестировании WebAPI один из вопросов заключается в том, каким образом построить модульные тесты так, чтобы можно было протестировать вызов нужных методов нужных контроллеров с нужными аргументами при отправке запроса на определенный url. Если предположить, что ответственность контроллера заключается только в том, чтобы перенаправить вызов соответствующему классу или компоненту системы, то ответ на вопрос о тестировании контроллера сведется к тому, чтобы перед запуском тестов динамически построить некое окружение, в котором контроллеру можно было бы отправлять нужные HTTP запросы и, используя mock'и, проверить правильность настроенного роутинга. При этом совершенно не хочется использовать для разворачивания тестового окружения IIS. В идеале, тестовое окружение должно создаваться перед запуском каждого теста. Это поможет модульным тестам быть достаточно изолированными друг от друга. С IIS в этом плане было бы достаточно непросто.

К счастью, с выходом .NET Framework 4.5 появилась возможность решить задачу тестирования роутинга достаточно просто. Например, используя следующие классы (в качестве DI контейнера используется Unity):

Код
public abstract class AbstractControllerTest<TController> where TController : ApiController
{
    private HttpServer _server;
    private HttpClient _client;
    private UnityContainer _unityContainer;

    [SetUp]
    public void BeforeTestExecuting()
    {
        _unityContainer = new UnityContainer();

        var configuration = new HttpConfiguration();

        WebApiConfig.Register(configuration, new IoCContainer(_unityContainer));

        _server = new HttpServer(configuration);
        _client = new HttpClient(_server);

        Register<TController>();
        RegisterConstructorDependenciesAndInjectionProperties(typeof(TController));
    }

    [TearDown]
    public void AfterTestExecuted()
    {
        _client.Dispose();
        _server.Dispose();
        _unityContainer.Dispose();
    }

    protected TestHttpRequest CreateRequest(string url)
    {
        return new TestHttpRequest(_client, url);
    }

    protected void Register<T>(T instance)
    {
        Register(typeof(T), instance);
    }

    private void Register(Type type, object instance)
    {
        _unityContainer.RegisterInstance(type, instance);
    }

    private void Register<T>()
    {
        _unityContainer.RegisterType<T>();
    }

    private void RegisterConstructorDependenciesAndInjectionProperties(Type controllerType)
    {
        var constructors = controllerType.GetConstructors();
        var constructorParameters = constructors
            .Select(constructor => constructor.GetParameters())
            .SelectMany(constructorParameters => constructorParameters);
        foreach (var constructorParameter in constructorParameters)
        {
            RegisterMockType(constructorParameter.ParameterType);
        }

        var injectionProperties = controllerType.GetProperties()
                    .Where(info => info.GetCustomAttributes(typeof(DependencyAttribute), false)
                    .Any());
        foreach (var property in injectionProperties)
        {
            RegisterMockType(property.PropertyType);
        }
    }

    private void RegisterMockType(Type parameterType)
    {
        dynamic mock = Activator.CreateInstance(typeof(Mock<>).MakeGenericType(parameterType), new object[] { MockBehavior.Default });
        Register(parameterType, mock.Object);
    }
}
public sealed class TestHttpRequest
{
    private readonly HttpClient _client;
    private readonly Uri _uri;

    public TestHttpRequest(HttpClient client, string url)
    {
        _client = client;
        _uri = new Uri(new Uri("http://can.be.anything/"), url);
    }

    public void AddHeader(string header, object value)
    {
        _client.DefaultRequestHeaders.Add(header, value.ToString());
    }

    public HttpResponseMessage Get()
    {
        return _client.GetAsync(_uri).Result;
    }

    public HttpResponseMessage Post(byte[] content)
    {
        return _client.PostAsync(_uri, new ByteArrayContent(content)).Result;
    }

    public HttpResponseMessage Put(byte[] content)
    {
        return _client.PutAsync(_uri, new ByteArrayContent(content)).Result;
    }

    public HttpResponseMessage Head()
    {
        var message = new HttpRequestMessage(HttpMethod.Head, _uri);
        return _client.SendAsync(message).Result;
    }
}


Теперь можно использовать эти классы для тестирования выдуманного контроллера, который возвращает сериализованные даты и объекты некоего класса Platform.
[TestFixture]
public class MyControllerTest : AbstractControllerTest<MyController>
{
    private MockRepository _mocks;

    protected override void OnSetup()
    {
        _mocks = new MockRepository(MockBehavior.Strict);
    }

    [Test]
    public void Test_GetDates()
    {
        //arrange
        var january = new DateTime(2013, 1, 1);
        var february = new DateTime(2013, 2, 1);

        var repositoryMock = _mocks.Create<IRepository>();
        repositoryMock
            .Setup(r => r.GetDates())
            .Returns(new[] {january, february});
        Register(repositoryMock.Object);

        //act
        var dates = ExecuteGetRequest<DateTime[]>("/api/build-dates");

        //assert
        _mocks.VerifyAll();

        Assert.That(dates, Is.EquivalentTo(new[] { january, february }));
    }

    [Test]
    public void Test_GetPlatforms()
    {
        //arrange
        var platform1 = new Platform {Id=1, Name = "1"};
        var platform2 = new Platform {Id=2, Name = "2"};

        var repositoryMock = _mocks.Create<IRepository>();
        repositoryMock
            .Setup(r => r.GetPlatforms())
            .Returns(new[] { platform1, platform2 });
        Register(repositoryMock.Object);

        //act
        var platforms = ExecuteGetRequest<Platform[]>("/api/platforms");

        //assert
        _mocks.VerifyAll();

        Assert.That(platforms, Is.EquivalentTo(new[] { platform1, platform2 }));
    }

    private T ExecuteGetRequest<T>(string uri)
    {
        var request = CreateRequest(url);
        var response = request.Get();
        T result;
        response.TryGetContentValue(out result);
        return result;
    }
}

Вот, собственно и все. Наши контроллеры покрыты модульными тестами.

Случай 3. Все остальное

А со всем остальным достаточно просто. Примеры модульных тестов на классы, которые содержат чистую логику, без взаимодействия с каким-либо внешним окружением, практически не отличаются от тех, которые предлагаются в популярной литературе типа «TDD by Example» Кента Бека. Поэтому каких-то особых хитростей здесь нет.

Добавлю, что помимо снижения количества ошибок в логике программы, от использования модульных тестов также можно получить следующие преимущества:

  • Упрощение архитектуры приложения. Основное правило здесь формулируется следующим образом: «Реализуется только то, что действительно нужно». Если в тесте описаны все сценарии и больше не удаётся придумать, как сломать логику, то нужно остановиться, привести код в более-менее приличный вид (выполнить рефакторинг) и со спокойной совестью лечь спать.
  • Документирование кода. Что может быть лучше, чем компилируемые и выполняемые примеры использования? Хорошо написанный тест является отличной документацией, которая, в отличие от комментариев, не потеряет своей актуальности. Если, конечно, будет контролироваться успешное прохождение тестов при изменении реализации логики программы.
  • «Подушка безопасности». Это, на мой взгляд, самое важное преимущество, которое можно получить от использования TDD в проекте. Тесты будут являться гарантией того, что программист, незнакомый с кодом, при внесении изменений сразу сможет увидеть, нарушили ли его изменения работу программы. Актуальные и полные модульные тесты дают отличную обратную связь. Кстати, в контексте «подушки безопасности» можно ответить на вопрос о целесообразности написания модульных тестов на, казалось бы, очевидный код. В случае командной разработки то, что очевидно одному разработчику может быть совершенно неочевидно другому. По разным причинам. В том числе и из-за различий в профессиональном уровне разработчиков (мы ведь помним про команду, верно?). И может сложиться ситуация, когда этому другому придётся вносить изменения в неочевидный или просто незнакомый для него код. И в этом случае модульные тесты могу уберечь систему от нарушения работоспособности и дать возможность разработчикам выполнять свои задачи более уверенно и эффективно.

Стоит, наверное, отметить, что перечисленные «плюшки» всегда будут свежими и вкусными при соблюдении принципа «test first». Изменились требования? Добавляем тест, изменяем код. Исправляем ошибку? Добавляем тест, изменяем код. Самое сложное — изменить восприятие тестов. Зачастую модульные тесты воспринимаются как нечто постороннее, чуждое «основному» коду. В этом и заключается, на мой взгляд, основное препятствие перед использованием TDD в полном объеме. И его нужно преодолеть, осознать, что модульные тесты и запрограммированный функционал — это части одного целого.

На сегодняшний день в проекте, над которым работает наша команда, около 1000 модульных тестов. Время сборки и запуска всех тестов на TeamCity составляет чуть больше 4 минут. Описанные в статье подходы позволяют нам тестировать практически все слои системы, контролируя изменение и развитие кода. Надеюсь, что наш опыт окажется для кого-нибудь полезным.