Зарегистрируйтесь для доступа к 15+ бесплатным курсам по программированию с тренажером

Тестирование кода, взаимодействующего с файлами PHP: Продвинутое тестирование

Наиболее типичный побочный эффект – взаимодействие с файлами (файловые операции). В основном это либо чтение файлов, либо запись в них. С чтением разбираться значительно проще, поэтому с него и начнём.

Чтение файлов

В большинстве случаев чтение файлов не доставляет особых проблем. Оно ничего не изменяет и выполняется локально, в отличие от сетевых запросов. Это значит, что при наличии необходимого файла и нужных прав, вероятность случайных ошибок крайне низка.

При тестировании функций, читающих файлы, должно выполняться ровно одно условие. Функция должна позволять менять путь до файла. В таком случае, достаточно создать файл нужной структуры в фикстурах.

<?php

// Функция читает файл со списком пользователей системы и возвращает их имена
// В линуксе это файл /etc/passwd
$userNames = readUserNames();

В тестах читать /etc/passwd (файл содержащий список пользователей системы в Linux) нельзя, потому что содержимое этого файла зависит от окружения, в котором запущены тесты. Для тестирования нужно создать файл аналогичной структуры в фикстурах и указать его при запуске функции:

<?php

public function getFixtureFullPath($fixtureName)
{
    $parts = [__DIR__, 'fixtures', $fixtureName];
    return realpath(implode('/', $parts));
}

public function testUserNames()
{
    // fixtures/passwd
    $passwdPath = $this->getFixtureFullPath('passwd');
    $userNames = readUserNames($passwdPath);
    $this->assertEquals(/* ожидаемый список */, $userNames);
}

Запись файлов

С записью файлов уже сложнее. Главная проблема – отсутствие гарантированной идемпотентности. Это значит, что повторный вызов функции, записывающей файлы, может вести себя не как первый вызов, например, завершаться с ошибкой, либо приводить к другим результатам.

Почему? Представьте себе, что мы пишем тесты на метод log($message), который дописывает все переданные в него сообщения в файл:

<?php

// Передаем файл в который нужно записывать логи
$logger = new Logger('/tmp/development.log');
$logger->log('first message');
// смотрим в файл: cat development.log
// first message
$logger->log('second message');
// cat development.log
// first message
// second message

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

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

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

<?php

class LoggerTest extends TestCase
{
    private $logfilePath = '/tmp/development.log';

    public function testLog(): void
    {
        $logger = new Logger($this->logfilePath);

        $logger->log('first message');
        $data1 = file_get_contents($this->logfilePath);
        $this->assertEquals(/* ... */, $data1);

        $logger->log('second message');
        $data2 = file_get_contents($this->logfilePath);
        $this->assertEquals(/* ... */, $data2);
    }

    public function tearDown(): void
    {
        unlink($this->logfilePath);
    }
}

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

Есть только один надёжный способ делать очистку – делать это до теста, а не после, в setUp(). С таким подходом есть только одна небольшая сложность. При первом запуске тестов файла нет. Это значит, что прямой вызов unlink() завершится с ошибкой и тесты не смогут выполниться. Чтобы избежать этого, можно ввести проверку:

<?php

public function setUp(): void
{
    if (file_exists($this->logfilePath)) {
        unlink($this->logfilePath);
    }
}

Другой вопрос при записи файлов. Куда их сохранять? Однозначно избегайте записи файлов прямо внутри проекта. Если тестируемый код позволяет сконфигурировать место записи, то используйте системную временную директорию. Её можно получить с помощью функции: sys_get_temp_dir()

<?php

echo sys_get_temp_dir();
// => /var/folders/v4/y35pw_5d0jv_ny30qmfw47dh0000gn/T

Виртуальная файловая система (ФС)

Это ещё один способ тестировать код, работающий с ФС. С помощью специальной библиотеки во время тестов создается виртуальная файловая система. Она автоматически подменяет реальную файловую систему. Это значит, что функцию, которая тестируется, трогать не надо. Эта функция продолжает думать, что она работает с реальным диском. Вся конфигурация при этом задаётся снаружи:

<?php

namespace tests;

use PHPUnit\Framework\TestCase;
use org\bovigo\vfs\{
    vfsStream,
    vfsStreamDirectory
};

class SomeTest extends TestCase
{
    /**
     * @var  vfsStreamDirectory
     */
    private $root;

    public function setUp(): void
    {
        $this->root = vfsStream::setup('exampleDir');
    }

    public function testDirectoryIsCreated()
    {
        $directoryPath = vfsStream::url('exampleDir');
        $innerDirectoryPath = $directoryPath . '/inner';
        mkdir($innerDirectoryPath);
        // Проверяем что внутри exampleDir есть inner
        $this->assertTrue($this->root->hasChild('inner'));
    }
}

Этот способ даёт идемпотентность из коробки. Вызов функции vfsStream::setup() формирует окружение на каждый запуск с нуля. То есть достаточно добавить её в setUp() и можно приступать к тестированию.


Дополнительные материалы

  1. Что такое логирование

Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты

Об обучении на Хекслете

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff
Рекомендуемые программы
профессия
от 6 300 ₽ в месяц
Разработка веб-приложений на Laravel
10 месяцев
с нуля
Старт 25 апреля

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»