Статьи

Внедрение зависимостей и слабая связь: как они влияют на вашу способность тестировать

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

Недавно я столкнулся с этой проблемой в проекте… Я обнаружил необходимость обновить класс Java, представляющий запланированное «задание», которое извлекало очень большой файл (содержащий сотни миллионов строк / записей) из удаленного URL-адреса и обрабатывало этот файл. , Вкратце, вот основные обязанности для этого класса:

  1. Загрузите файл с URL в локальную файловую систему.
  2. Проанализируйте каждую строку, содержащуюся в загруженном файле, и проверьте, представляет ли строка запись, в которой заинтересовано приложение.
  3. Для каждой интересующей строки обновите базу данных приложения — в зависимости от содержимого строки эта строка либо будет проигнорирована, либо помечает класс для удаления существующих записей или добавления новых записей в несколько таблиц в базе данных приложения.

Именно эти две последние функции заставили нас обновить алгоритм класса, потому что при обработке файлов, содержащих такое огромное количество записей, которые нужно проанализировать и обработать, задание обычно выполнялось не менее пары дней. Это фон, но реальная цель этого поста — функция номер один выше — загрузка файла с удаленного URL. Со всеми изменениями, которые я сделал для оптимизации алгоритма, мне нужно было проверить поведение класса — не было никакого модульного теста, и я хотел написать его. Идея модульного тестирования состоит в том, чтобы изолировать модуль (почти всегда один класс — «тестируемый класс»). Однако я столкнулся с некоторыми проблемами:

  • Тестируемый класс вызывал закрытый метод для загрузки файла. Почему это проблема? С помощью этой реализации я не могу контролировать, что внешний веб-сайт вернет мне в любой момент времени, так как я могу написать тест, который проверяет, правильно ли мой класс обрабатывает возвращенный файл? Кроме того, очень плохо, когда юнит-тесты взаимодействуют с любой внешней системой, особенно если это производственная система. И, если эта система не работает или сломана, мои модульные тесты будут ломаться, хотя мой код может быть просто в порядке.
  • Тестируемый класс непосредственно создает экземпляры объектов доступа к данным (DAO), которые он использует для обновления базы данных приложения. Почему это проблема? Хотя я действительно забочусь о том, что данные, проанализированные из загруженного файла, корректно обновляются в базе данных приложения, это не является целью модульного теста — я могу позже написать более всеобъемлющий интеграционный тест, чтобы проверить более полный «поток», но это Я не хочу действительно обновлять свою базу данных — только проверяю, что мой тестируемый класс вызывает DAO, когда и с параметрами, которые я ожидаю. Путем создания экземпляров DAO напрямую, тестируемый класс вмешивался в моя способность контролировать это.

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

public class BadSyncDBWithExternalSiteJob {
    private static final String REMOTE_SITE_URL = "http://mysite.com/update-file.txt";
 
    private static final String DOWNLOADED_FILE_LOCATION = "./downloadedFileToParse";
 
    private SiteSyncDAOImpl siteSyncDAO = new SiteSyncDAOImpl();
 
    public void execute() throws Exception {
        File downloadedFileToParse = retrieveRemoteFile();
        // Parse file, update local database via the siteSyncDAO
    }
 
    private File retrieveRemoteFile() throws Exception {
        File downloadedFile = null;
        BufferedInputStream in = null;
        FileOutputStream fout = null;
        // Yes, there are better ways to do this - e.g. Apache's FileUtils
        try {
            in = new BufferedInputStream(new URL(REMOTE_SITE_URL).openStream());
            fout = new FileOutputStream(DOWNLOADED_FILE_LOCATION);
 
            byte data[] = new byte[1024];
            int count;
            while ((count = in.read(data, 0, 1024)) != -1) {
                fout.write(data, 0, count);
            }
 
            downloadedFile = new File(DOWNLOADED_FILE_LOCATION);
        } finally {
            if (in != null)
                in.close();
            if (fout != null)
                fout.close();
        }
        return downloadedFile;
    }
 
}

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

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

public class GoodSyncDBWithExternalSiteJob {
    private ExternalFileDownloader externalSiteFileDownloader = null;
 
    private SiteSyncDAO siteSyncDAO = null;
 
    public void setSiteSyncDAO(SiteSyncDAO siteSyncDAO) {
        this.siteSyncDAO = siteSyncDAO;
    }
 
    public void setExternalSiteFileDownloader(ExternalFileDownloader externalSiteFileDownloader) {
        this.externalSiteFileDownloader = externalSiteFileDownloader;
    }
 
    public void execute() throws Exception {
        File downloadedFileToParse = externalSiteFileDownloader.retrieveRemoteFile();
        // Parse file, update local database via the siteSyncDAO
 
    }
}
public interface ExternalFileDownloader {
    public void setRemoteSiteURL(String remoteSiteURL);
 
    public void setDownloadedFileLocation(String downloadedFileLocation);
 
    public File retrieveRemoteFile() throws Exception;
}
public class ExternalURLFileDownloader implements ExternalFileDownloader {
    private String remoteSiteURL = null;
 
    private String downloadedFileLocation = null;
 
    public void setRemoteSiteURL(String remoteSiteURL) {
        this.remoteSiteURL = remoteSiteURL;
    }
 
    public void setDownloadedFileLocation(String downloadedFileLocation) {
        this.downloadedFileLocation = downloadedFileLocation;
    }
 
    public File retrieveRemoteFile() throws Exception {
        File downloadedFile = null;
        BufferedInputStream in = null;
        FileOutputStream fout = null;
        // Yes, there are better ways to do this - e.g. Apache's FileUtils
        try {
            in = new BufferedInputStream(new URL(remoteSiteURL).openStream());
            fout = new FileOutputStream(downloadedFileLocation);
 
            byte data[] = new byte[1024];
            int count;
            while ((count = in.read(data, 0, 1024)) != -1) {
                fout.write(data, 0, count);
            }
 
            downloadedFile = new File(downloadedFileLocation);
        } finally {
            if (in != null)
                in.close();
            if (fout != null)
                fout.close();
        }
        return downloadedFile;
    }
 
}

Обратите внимание на следующее о классе GoodSyncDBWithExternalSiteJob:

  • Теперь требуется внедрение экземпляра интерфейса ExternalFileDownloader для обработки всех деталей загрузки файла, включая информацию о том, где и как. Фактически, GoodSyncDBWithExternalSiteJob больше не знает ничего о файле, даже о том, был ли он загружен из удаленного местоположения. Наши модульные тесты могут внедрить «фиктивную» версию ExternalFileDownloader, которая возвращает «консервированный» файл, содержащий одни и те же записи, каждый раз, когда мы выполняем наши модульные тесты.
  • Также требуется инъекция DAO. Я не буду фокусироваться на DAO — те же концепции, которые обсуждались для «загрузчика файлов», применимы и к DAO. Класс задания не должен решать, какую реализацию DAO он должен использовать, и создавать его экземпляр — мы должны иметь возможность «связать» реализацию DAO, которую мы хотим использовать во время выполнения.

ПРИМЕЧАНИЕ. Для краткости примера кода я проигнорировал хорошие методы обработки исключений. Мы все знаем, что не рекомендуется, чтобы наши методы генерировали общие экземпляры «исключений». Мы делаем, не так ли? Пожалуйста скажи да.»:-)

Помимо обеспечения возможности тестирования, эти простые изменения предоставляют и другие мощные преимущества:

  • Слабое связывание: класс, который анализирует и решает, что необходимо обновить в базе данных, теперь полностью изолирован от деталей, из которых извлекается файл и как он извлекается. Теперь у меня есть контроль над «сотрудниками» класса. Я могу внедрить фиктивные экземпляры — мои собственные фиктивные объекты или объекты, созданные с помощью «фиктивной» инфраструктуры, такой как EasyMock, — которые ведут себя в соответствии с моими потребностями в тестировании и возвращают объекты в мой тестируемый класс предсказуемым образом каждый раз, когда я запускаю свои тесты.
  • Сплоченность: классы теперь имеют четко определенные, узкие обязанности. Поскольку приложения становятся больше, значение этого атрибута не может быть завышено в целях удобства обслуживания и понятности.
  • Более чистый и простой дизайн: в связи с вышеизложенным, это просто дизайн, который легче понять, изменить и поддерживать.

Это пример того, где следование философии дизайна, основанного на тестировании (TDD), может окупиться Если бы разработчик сначала писал свои модульные тесты или даже в тандеме с реализацией производственного кода, такого радикального рефакторинга можно было бы избежать, и этот класс был бы модульным с самого начала. Вы можете утверждать, что разработчик всегда может провести рефакторинг позже для достижения этой цели. Однако, проблемы с этой точки зрения, по моему опыту:

  • Разработчикам редко предоставляется время, необходимое для реализации рефакторинга существующего кода, а тем более для написания модульных тестов для кода, который воспринимается как «работающий нормально сейчас».
  • Как правило, гораздо сложнее выяснить, как выполнить рефакторинг и модульное тестирование существующего кода, чем сделать несколько простых шагов, чтобы очистить проект и написать тесты с самого начала.
  • Во многих случаях тестируемый класс будет использоваться несколькими компонентами приложения, что может означать, что разработчик теперь должен рассмотреть все способы использования класса. Это может привести к тому, что разработчик попадет в сценарий «это изменение работает для этого варианта использования и нарушает другой сценарий использования». Я лично сталкивался с случаями, когда попытка рефакторинга существующего класса, который казался очень простым, требовала нескольких дней усилий.

Я расскажу о некоторых из этих тем — слабая связь, TDD, фиктивные фреймворки и т. Д. — в будущих публикациях в блоге. На данный момент нам достаточно начать, просто не забывая спроектировать наши классы таким образом, чтобы их обязанности были довольно узкими и чтобы их соавторы / зависимости могли быть внедрены во время выполнения и определены через интерфейсы вместо конкретных реализаций. Как только мы сделаем это в рамках нашей стандартной практики, наши проекты будут более чистыми и гибкими, а наше модульное тестирование будет проще и эффективнее.