Модульное тестирование и контракты

Tags: tdd, code contracts, unit testing, defensive programming

Я давно хотел попробовать использовать контракты в дизайне своего кода, но всё как-то руки не доходили. Для целей defensive programming мне было вполне достаточно привычного подхода if-then-throw в начале каждого метода. Однако, руки наконец-то дошли, подвернулся новый несложный проект, и я решил включить эту технику в свой инвентарь. Как минимум это позволит мне писать более безопасный код, так как я буду проверять не только предусловия, но и постусловия и состояние объектов. И сразу же возник вопрос: “как такой код тестировать”. Вроде бы всё просто, заменяй тип ожидаемого исключения в модульных тестах с разнообразных ArgumentException, ArgumentNullException на тип, который используется при нарушении контрактов, но не тут-то было! Действительно контракты выбрасывают, при своём нарушении, исключение типа ContractException, но этот тип объявлен внутренним (internal).

То есть подобный код просто не будет скомпилирован:

        [TestMethod]
        [ExpectedException(System.Diagnostics.Contracts.ContractException)]
        public void TestMethodName()
        {
            new ClothesStyle(null, 0);
        }

А нужно ли их вообще тестировать?

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

И всё же какой подход использовать?

С тем, что тестировать контракты необходимо, мы определились. Как это делать?

Один из вариантов предполагает использование статического события Contract.ContractFailed. Это событие предназначено для создания централизованного обработчика всех событий, связанных с нарушением любых контрактов. Можно воспользоваться им и назначить обработчик, например, в конструкторе класса-теста. В обработчике проверять тип исключения (с помощью Reflection) и, если это исключение нарушения контракта, выбрасывать какое-то другое исключение, например, собственное ContractFailedException. И в коде теста использовать именно этот тип для ExpectedExceptionAttribute или в try/catch блоке. На мой взгляд это решение несколько “корявое”. Проще, в таком случае, просто оборачивать каждое выражение, которое должно нарушить контракт, в try/catch блок, ожидающий исключение типа Exception, а в catch вызывать Assert.Fail().

Я придумал другой способ. Его суть в следующем: создать статический класс, в который входят два метода ContractFailure и NoContractFailure. Первый должен проверять, что произошло ожидаемое нарушение контракта, второй – нарушения контракта не произошло. Далее реализация этого класса, тестируемый класс и модульный тест.

// Проект: Eshva.Framework.UnitTests
// Имя файла: AssertEx.cs
// GUID файла: E00048F2-0535-4EF0-AD6D-C03398DB3CEB
// Автор: Mike Eshva (mike@eshva.ru)
// Дата создания: 30.06.2012

using System;
using System.Diagnostics;
using System.Diagnostics.Contracts;
using System.Reflection;
using Microsoft.VisualStudio.TestTools.UnitTesting;


namespace Eshva.Framework.UnitTests
{
    /// <summary>
    /// Расширения для проверки в модульных тестах.
    /// </summary>
    public static class AssertEx
    {
        #region Public methods

        /// <summary>
        /// Проверяет, что при выполнении тестируемого кода <paramref name="aTestingCode"/> было
        /// нарушено условие контракта.
        /// </summary>
        /// <param name="aTestingCode">
        /// Тестируемый код.
        /// </param>
        /// <param name="aMessage">
        /// Опциональное сообщение об ошибки в случае, если код не нарушил контракт.
        /// </param>
        /// <exception cref="ArgumentNullException">
        /// Тестируемый код не задан.
        /// </exception>
        public static void ContractFailure(Action aTestingCode, string aMessage = null)
        {
            if (aTestingCode == null)
            {
                throw new ArgumentNullException("aTestingCode", "Тестируемый код не задан.");
            }

            try
            {
                aTestingCode();
            }
            catch (Exception lException)
            {
                if (lException.GetType().FullName != ContractExceptionName)
                {
                    Assert.Fail(
                        "Ожидалось нарушение контракта, но было выброшено другое исключение с типом {0} и сообщением '{1}'.",
                        lException.GetType().FullName,
                        lException.Message);
                }

                return;
            }

            Assert.Fail(
                !string.IsNullOrWhiteSpace(aMessage)
                    ? aMessage
                    : "Ожидалось, что вызываемый код нарушит контракт, однако этого не произошло.");
        }

        /// <summary>
        /// Проверяет, что при выполнении тестируемого кода <paramref name="aTestingCode"/> не было
        /// нарушено условие контракта.
        /// </summary>
        /// <param name="aTestingCode">
        /// Тестируемый код.
        /// </param>
        /// <param name="aMessage">
        /// Опциональное сообщение об ошибки в случае, если код нарушил контракт.
        /// </param>
        /// <exception cref="ArgumentNullException">
        /// Тестируемый код не задан.
        /// </exception>
        public static void NoContractFailure(Action aTestingCode, string aMessage = null)
        {
            if (aTestingCode == null)
            {
                throw new ArgumentNullException("aTestingAction", "Тестируемый код не задан.");
            }

            try
            {
                aTestingCode();
            }
            catch (Exception lException)
            {
                if (lException.GetType().FullName == ContractExceptionName)
                {
                    string lContractErrorMessage = GetContractErrorMessage(lException);
                    Assert.Fail(lContractErrorMessage);
                }

                throw;
            }
        }

        #endregion

        #region Private methods

        private static string GetContractErrorMessage(Exception aException)
        {
            Debug.Assert(aException != null);
            Type lExceptionType = aException.GetType();
            Debug.Assert(lExceptionType.FullName == ContractExceptionName);

            PropertyInfo lKindPropertyInfo = lExceptionType.GetProperty("Kind");
            ContractFailureKind lKind =
                (ContractFailureKind) lKindPropertyInfo.GetValue(aException, null);
            PropertyInfo lFailurePropertyInfo = lExceptionType.GetProperty("Failure");
            string lFailure = (string) lFailurePropertyInfo.GetValue(aException, null);
            PropertyInfo lUserMessagePropertyInfo = lExceptionType.GetProperty("UserMessage");
            string lUserMessage = (string) lUserMessagePropertyInfo.GetValue(aException, null);
            PropertyInfo lConditionPropertyInfo = lExceptionType.GetProperty("Condition");
            string lCondition = (string) lConditionPropertyInfo.GetValue(aException, null);

            string lMessage = !string.IsNullOrWhiteSpace(lUserMessage) ? lUserMessage : lFailure;
            string lResult =
                string.Format(
                    "Был нарушен {0} контракт {1}. Сообщение об ошибке: '{2}'.",
                    lKind.ToString(),
                    lCondition,
                    lMessage);

            return lResult;
        }

        #endregion

        #region Private data

        private const string ContractExceptionName =
            "System.Diagnostics.Contracts.__ContractsRuntime+ContractException";

        #endregion
    }
}

 


Тестируемый класса:

// Проект: Eshva.DomainModel
// Имя файла: ClothesStyle.cs
// GUID файла: 21A1309B-AE66-439E-A5E8-6ECAD68813B2
// Автор: Mike Eshva (mike@eshva.ru)
// Дата создания: 30.06.2012

using System.Diagnostics.Contracts;


namespace Eshva.DomainModel
{
    /// <summary>
    /// Модель одежды.
    /// </summary>
    public class ClothesStyle
    {
        #region Constructors

        /// <summary>
        /// Инициализирует новый экземпляр модели одежды названием модели и её ценой.
        /// </summary>
        /// <param name="aName">
        /// Название модели.
        /// </param>
        /// <param name="aPrice">
        /// Цена модели.
        /// </param>
        public ClothesStyle(string aName, decimal aPrice)
        {
            Contract.Requires(!string.IsNullOrWhiteSpace(aName), "Название модели не задано.");
            Contract.Requires(aPrice > 0m, "Цена модели не задана.");
            Contract.Ensures(
                !string.IsNullOrWhiteSpace(Name),
                "Название модели должно быть сохранено в свойстве Name.");
            Contract.Ensures(Price > 0m);

            Name = aName;
            Price = aPrice;
        }

        #endregion

        #region Public properties

        /// <summary>
        /// Получает название.
        /// </summary>
        public string Name { get; private set; }

        /// <summary>
        /// Получает цены.
        /// </summary>
        public decimal Price { get; private set; }

        #endregion
    }
}

 


Модульные тесты:

// Проект: Eshva.DomainModel.Tests
// Имя файла: ClothesStyleTests.cs
// GUID файла: A128048D-2A88-4EEA-BBA5-AFA6D563982C
// Автор: Mike Eshva (mike@eshva.ru)
// Дата создания: 30.06.2012

using Eshva.Pizazz.DomainModel.Tests.Framework;
using Microsoft.VisualStudio.TestTools.UnitTesting;


namespace Eshva.DomainModel.Tests
{
    /// <summary>
    /// Tests for
    /// </summary>         
    [TestClass]           
    public class ClothesStyleTests
    {
        #region Public methods

        [TestMethod]
        public void КонструкторДолженКорректноНазначатьЗначенияСвойствам()
        {
            ClothesStyle lStyle = new ClothesStyle(ClothesName01, ClothesPrice01);
            Assert.AreEqual(
                ClothesName01, lStyle.Name, "Название модели не соответствует ожидаемому.");
            Assert.AreEqual(ClothesPrice01, lStyle.Price, "Цена модели не соответствует ожидаемой.");
        }

        [TestMethod]
        public void ПроверкаКонтрактовКонструктора()
        {
            AssertEx.ContractFailure(() => new ClothesStyle(null, ClothesPrice01));
            AssertEx.ContractFailure(() => new ClothesStyle(ClothesName01, -1m));
            AssertEx.ContractFailure(() => new ClothesStyle(ClothesName01, 0m));
            AssertEx.NoContractFailure(() => new ClothesStyle(ClothesName01, ClothesPrice01));
        }

        #endregion

        #region Private data

        private const string ClothesName01 = "Платье из шёлка";
        private const decimal ClothesPrice01 = 12000;

        #endregion
    }
}

Для того, чтобы увидеть как работает AssertEx.NoContractFailure, необходимо закомментировать, например, сточку в конструкторе:

Name = aName;

  

Для того, чтобы увидеть как работает AssertEx.ContractFailure, необходимо закомментировать, например, сточку в конструкторе:

Contract.Requires(!string.IsNullOrWhiteSpace(aName), "Название модели не задано.");

В целом метод AssertEx.ContractFailure полезен для тестирования предусловий, в свою очередь AssertEx.NoContractFailure – для тестирования постусловий и состояния объекта.