Улучшите тестируемость, обернув статические классы
Некоторые части платформы .NET представлены как статические классы, например File
, Directory
а также Path
. Поскольку статические классы нельзя имитировать, очень сложно тестировать компоненты, которые их используют. Кроме того, некоторые статические классы могут иметь внешние побочные эффекты или зависимости, например File.ReadAllText
требует, чтобы фактический физический файл находился в указанном месте, иначе вызов завершится ошибкой. Это усложняет наш тестовый код, требуя от нас создания файла в известном месте для теста, а затем его очистки. Это также нарушает правило «Тестирование в изоляции», поскольку теперь тест зависит от фактической базовой файловой системы.
В этом посте демонстрируется простой и относительно простой способ улучшить тестируемость классов, использующих статические классы.
Проект размещен в Репозиторий GitHub содержащий две ветви:
- трудно тестируемый: Эта ветвь напрямую использует
System.Io.File
для чтения файла и иллюстрирует пару распространенных проблем при тестировании кода, использующего статические классы. - с фреймворками-обертками: эта ветвь вводит простую оболочку для инкапсуляции вызовов
System.Io.File
и улучшить тестируемость
Решение представляет собой простой пример, состоящий из двух проектов:
- Tdd.FrameworkWrappers.Lib: Тестируемая реализация. Он содержит один класс,
FileReader
который читает файл из хранилища и возвращает его содержимое в виде строки. - Tdd.FrameworkWrappers.Lib.Tests: тесты NUnit для
Tdd.FrameworkWrappers.Lib
проект.
Начнем с клонирования difficult-to-test
ветвь и открытие решения.
Если мы посмотрим на FileReader.cs, мы увидим, что его ReadText
метод просто делегирует System.Io.File.ReadAllText
и возвращает свой результат:
Однако все становится более запутанным, когда мы копаемся в FileReaderTests.cs
. Прямое использование System.Io.File
несколько усложняет наше тестирование:
- Мы не можем проверить взаимодействие между
FileReader
а такжеSystem.Io.File
потому что статические классы нельзя издеваться - Нам нужно добавить методы до и после тестирования для создания и очистки тестового файла в системе. Это само по себе может быть проблематичным, поскольку чтение/запись/удаление файлов может быть подвержено ошибкам (т. е. IoExceptions), поэтому наш тест может предположительно провалиться по причинам, выходящим за рамки теста.
Мы можем решить эту проблему тестируемости, обернув System.Io.File
в другом нестатическом классе, который мы контролируем. Тем самым мы можем раскрыть IFile
интерфейс (чтобы мы могли имитировать и поддерживать внедрение зависимостей). IFile
интерфейс реализован FileImpl
который будет конкретным типом, внедренным в экземпляры FileReader
нашим контейнером IoC.
Итак, без лишних слов, давайте копать!
Теперь давайте клонируем with-framework-wrappers
ветку и откройте решение.
Мы начнем с того же места, что и раньше, в FileReader.cs
. Обратите внимание, что теперь есть конструктор, который принимает IFile
а также ILogger
экземпляр, и что ReadText
метод теперь вызывает IFile.ReadAllText
вместо File.ReadAllText
.
Вы также заметите, что появился новый FrameworkWrappers
папка, так что давайте копаться в этом. FrameworkWrappers
содержит IFile
и его реализация, FileImpl
. Глядя на интерфейс, мы видим, что он выставляет ReadAllText
метод, сигнатура которого совпадает с сигнатурой System.Io.File.ReadAllText
:
Переходим к FileImpl
мы видим, что он делает именно то, что FileReader
делал раньше: просто делегировал вызов System.Io.File.ReadAllText
и возвращая результат:
Если мы теперь перейдем к тестам, то увидим еще некоторые изменения, а именно:
-
CreateTestFile
а такжеCleanupTestFile
методы исчезли, потому что нам больше не нужно писать в файловую систему, чтобы выполнить этот тест (например, мы удалили зависимость теста от базовой файловой системы) SetUp
был изменен для создания экземпляраMock<IFile>
а такжеMock<ILogger>
и вводить их вFileReader
под тестомReadText_WhenFileExists_ReturnsFileContents
существенно упрощенReadText_Always_PerformsExpectedWork
теперь можно реализовать — поскольку мы используем макет, мы можем убедиться, чтоReadText
метод действительно вызываетReadAllText
метод наIFile
с ожидаемыми аргументами.- Теперь мы также можем тестировать «грустные» потоки, например, «Делает ли
FileReader
реагировать соответствующим образом, если исключение выдаетсяIFileReader.ReadAllText
?»ReadText_WhenIoExceptionThrown_PerformsExpectedWork
иллюстрирует эту новую возможность - Тестовые случаи параметризуются с помощью
TestCaseSourceAttribute
. На самом деле это не относится к тестированию с помощью Framework Wrappers, но это просто способ повторного использования тестовых случаев.
Вы можете заметить, что нет соответствующего FileImplTests
в тестовом проекте это нормально для чистый оболочки, например классы, которые просто делегируют вызовы внешнему классу (в данном случае библиотеке Microsoft) без добавления какой-либо логики. Причиной этого является ожидание того, что Microsoft (или сторонний поставщик) будет нести ответственность за тестирование своих собственных библиотек, поэтому тестирование оболочки будет излишним.
Итак, в двух словах, то, что мы сделали здесь, это улучшили тестируемость, добавив тонкий слой косвенности вокруг System.Io.File
class, что позволяет нам ввести интерфейс (для DI и Mocking), реализованный контролируемым нами классом. Конечным результатом является то, что мы можем эффективно тестировать наши FileReader
класс в полной изоляции от внешних зависимостей.
Я склонен следовать этому шаблону почти каждый раз, когда мне нужно использовать статический класс, например File
, Directory
, Path
, ConfigurationManager
и т. д. Я также использовал вариант этого шаблона с приложениями WPF, заключая компоненты пользовательского интерфейса, не поддерживающие WPF, в прикрепленное поведение, чтобы облегчить связь между ViewModels и устаревшими компонентами пользовательского интерфейса.