Покрытия кода недостаточно

Охват кода уже несколько лет является важным показателем качества для измерения устойчивости кода. Это часть критериев выпуска для многих организаций, от разработки до контроля качества, а иногда и для высшего руководства. На самом деле нет ничего необычного в том, что несколько команд стремятся к более высокой метрике покрытия кода где-то от 60 до 80% в качестве минимального порогового значения для выпуска. При всем вышесказанном важно понимать, что покрытие кода само по себе не так полезно. Например, даже если у вас было очень высокое покрытие кода, составляющее 80%, и большая часть вашей базовой логики заключалась в этих остальных 20% кода, высока вероятность, что код может иметь серьезные дефекты, которые могут проскользнуть в выпуск. Что еще хуже, даже если покрытие кода было 100%, это не означает, что были покрыты все функции! Могут быть важные функциональные сценарии, которые могли быть пропущены в производственном коде и модульных тестах, что снова может привести к неполному выпуску. Не поймите меня неправильно. Охват кода важен, но не тогда, когда он рассматривается изолированно. В сочетании с хорошими функциональными модульными тестами это может быть чрезвычайно полезно. И это то, что мы рассмотрим в этой статье.

Давайте посмотрим код

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

  • Получите подробную информацию о продукте.
  • Узнай цену продукта.
  • Получите изменение цены на товар.

Для поддержки этой функциональности у нас есть 3 класса.

  • InventoryManagerDelegate: содержит основную бизнес-логику и обычно будет использоваться другим уровнем или уровнем, например уровнем REST API, чтобы предлагать API управления запасами.
  • InventoryDAO: объект доступа к данным для обработки всех взаимодействий с базой данных, таких как добавление и получение продукта.
  • Продукт: модель или объект данных для хранения информации о продукте (также известный как POJO (простой старый объект Java) на жаргоне Java).

Вот исходный код этих классов.

InventoryManagerDelegate.java

package com.cloudnineapps.samples;

/**
 * The inventory manager delegate.
 */
public class InventoryManagerDelegate {

  /** The DAO. */
  private InventoryDAO dao;
  
  
  /** Initializes the delegate. */
  public void init() throws Exception {
    dao = new InventoryDAO();
    dao.createSchema();
  }

  /**
   * Returns the details for the product with the supplied title.
   * 
   * @param title The product title.
   */
  public Product getProductDetails(String title) throws Exception {
    Product product = null;
    try {
      product = dao.getProduct(title);
    }
    catch(IllegalArgumentException ex) {
      // Product not found, handle gracefully
      product = new Product();
    }
    
    return product;
  }
  
  /**
   * Returns the price for the specified product.
   * 
   * @param title The product title.
   */
  public double getProductPrice(String title) throws Exception {
    // NOTE: This code does not gracefully handle the product not found scenario
    Product product = dao.getProduct(title);
    return product.getPrice();
  }
  
  /**
   * Returns the price change for the specified product from the current price. A negative value
   * indicates decrease in price and a positive value indicates an increase.
   * 
   * @param title The product title.
   * @param currentPrice The current price of the product.
   */
  public double getProductPriceChange(String title, double currentPrice) throws Exception {
    // NOTE: This code does not gracefully handle the product not found scenario
    double price = 0;
    try {
      Product product = dao.getProduct(title);
      price = product.getPrice();
    }
    catch(Exception ex) {
    }
    return (currentPrice - price);
  }
  
  /**
   * Adds a product with the supplied details.
   * 
   * @param id The product ID.
   * @param title The product title.
   * @param description The product description.
   * @param price The product price.
   */
  public void addProduct(int id, String title, String description, double price) throws Exception {
    dao.addProduct(id, title, description, price);
  }
}

InventoryDAO.java

package com.cloudnineapps.samples;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;

/**
 * The inventory DAO.
 */
public class InventoryDAO {
  
  /** The database name. */
  private static final String DB_NAME = "inventory.db";

  /** Returns the current database connection. */
  public Connection getConnection() throws Exception {
    return DriverManager.getConnection(String.format("jdbc:hsqldb:file:data/%s", DB_NAME), "SA", "");
  }
  
  /** Closes the supplied connection. */
  public void closeConnection(Connection con) throws Exception {
    if (con != null) {
      con.close();
    }
  }
  
  /** Creates the database schema. */
  public void createSchema() throws Exception {
    String[] sqls = {
      "CREATE TABLE IF NOT EXISTS product("
        + "id INT NOT NULL,"
        + "title VARCHAR(255) NOT NULL,"
        + " description VARCHAR(255) NOT NULL,"
        + " price REAL NOT NULL,"
        + " PRIMARY KEY(id));",
      "CREATE UNIQUE INDEX IF NOT EXISTS idx_product_title ON product(title);"
    };
    Connection con = getConnection();
    try {
      for(int i = 0; i < sqls.length; i++) {
        Statement stmt = con.createStatement();
        stmt.execute(sqls[i]);
        stmt.close();
      }
    }
    finally {
      closeConnection(con);
    }
  }
  
  /**
   * Returns the product with the specified title, if any. Otherwise, null.
   * 
   * @param title The product title.
   * 
   * @exception IllegalArgumentException when the title was not found.
   */
  public Product getProduct(String title) throws Exception {
    Product product = null;
    String sql = "SELECT id, description, price FROM product WHERE title=?";
    Connection con = getConnection();
    PreparedStatement stmt = null;
    ResultSet rs = null;
    try {
      stmt = con.prepareStatement(sql);
      stmt.setString(1, title);
      rs = stmt.executeQuery();
      if (rs.next()) {
        int i = 1;
        int id = rs.getInt(i++);
        String description = rs.getString(i++);
        double price = rs.getDouble(i++);
        product = new Product();
        product.setId(id);
        product.setTitle(title);
        product.setDescription(description);
        product.setPrice(price);
      }
      else {
        // Product not found
        throw new IllegalArgumentException(String.format("A product with title '%s' not found.", title));
      }
    }
    finally {
      rs.close();
      stmt.close();
      closeConnection(con);
    }
        
    return product;
  }
  
  /**
   * Adds a product with the supplied details.
   * 
   * @param id The product ID.
   * @param title The product title.
   * @param description The product description.
   * @param price The product price.
   */
  public void addProduct(int id, String title, String description, double price) throws Exception {
    String sql = "INSERT INTO product(id, title, description, price) VALUES(?, ?, ?, ?)";
    try {
      if (getProduct(title) != null) {
        // Product already exists, for now skip processing
        return;
      }
    }
    catch(IllegalArgumentException ex) {}
    Connection con = getConnection();
    PreparedStatement stmt = null;
    try {
      stmt = con.prepareStatement(sql);
      int i = 1;
      stmt.setInt(i++, id);
      stmt.setString(i++, title);
      stmt.setString(i++, description);
      stmt.setDouble(i++, price);
      stmt.executeUpdate();
    }
    finally {
      stmt.close();
      closeConnection(con);
    }
  }
}

Product.java

package com.cloudnineapps.samples;

/**
 * The product.
 */
public class Product {
  
  private int id;
  private String title;
  private String description;
  private double price;

  public int getId() {
    return id;
  }
  
  public void setId(int id) {
    this.id = id;
  }
  
  public String getTitle() {
    return title;
  }
  
  public void setTitle(String title) {
    this.title = title;
  }
  
  public String getDescription() {
    return description;
  }
  
  public void setDescription(String description) {
    this.description = description;
  }
  
  public double getPrice() {
    return price;
  }
  
  public void setPrice(double price) {
    this.price = price;
  }
}

Большая часть кода не требует пояснений. Но вот некоторые основные моменты.

  • Делегат инициализирует DAO, который инициализирует схему.
  • Делегат предлагает 3 метода, которые соответствуют 3 основным вариантам использования - getProductDetails(), getProductPrice() и getProductPriceChange().
  • Вы, возможно, уже заметили в комментариях к телам методов getProductPrice() и getProductPriceChange(), что они не обрабатывают сценарий, когда продукт с указанным заголовком не найден.

Давайте проведем модульное тестирование этого кода

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

package com.cloudnineapps.samples;

import junit.framework.TestCase;

/**
 * The unit tests for Inventory Manager delegate.
 */
public class TestInventoryManagerDelegate extends TestCase {

  /** The delegate. */
  private InventoryManagerDelegate delegate;
  
  
  public void setUp() throws Exception {
    delegate = new InventoryManagerDelegate();
    delegate.init();
    delegate.addProduct(1, "TV", "4K TV", 299.99);
    delegate.addProduct(2, "Receiver", "4K Receiver", 149.99);
    delegate.addProduct(3, "Audio System", "Surround Sound", 199.99);
  }
  
  /** Tests retrieval of product details successfully. */
  public void testGetProductDetailsSuccess() throws Exception {
    // Initialize
    String title = "TV";
    
    // Execute
    Product product = delegate.getProductDetails(title);
    
    // Validate
    assertEquals("Incorrect title.", title, product.getTitle());
  }
  
  /** Tests retrieval of product details when title does not exist. */
  public void testGetProductDetailsWithNonExistingTitle() throws Exception {
    // Initialize
    String title = "Remote";
    
    // Execute
    Product product = delegate.getProductDetails(title);
    
    // Validate
    assertNull("The title must be null.", product.getTitle());
  }
  
  /** Tests retrieval of product price successfully. */
  public void testGetProductPriceSuccess() throws Exception {
    // Initialize
    String title = "TV";
    double expectedPrice = 299.99;
    
    // Execute
    double price = delegate.getProductPrice(title);
    
    // Validate
    assertEquals("Incorrect price.", expectedPrice, price);
  }
  
  /** Tests retrieval of product price change successfully. */
  public void testGetProductPriceChangeSuccess() throws Exception {
    // Initialize
    String title = "TV";
    double currentPrice = 249.99;
    double expectedPriceChange = -50;
    
    // Execute
    double price = delegate.getProductPriceChange(title, currentPrice);
    
    // Validate
    assertEquals("Incorrect price change.", expectedPriceChange, price);
  }
}

Довольно прямолинейно, правда?

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

Давайте создадим покрытие кода

Прежде чем мы сможем говорить о том, почему покрытия кода недостаточно, давайте сгенерируем его для этого кода. Вот несколько скриншотов.

Общее покрытие кода

Распределение покрытия кода

Покрытие кода метода getProduct() DAO

Как видим, покрытие кода довольно хорошее.

Почему охвата кода недостаточно?

Теперь мы можем поговорить о том, почему покрытия кода недостаточно. В коде есть 2 часто наблюдаемые проблемы при кодировании.

  1. Отсутствует код для функциональности: метод getProductPrice() не имеет кода для корректной обработки сценария, когда продукт не найден. Вместо этого он просто распространяет исключение.
  2. Функциональный код неисправен: метод getProductPriceChange() неправильно обрабатывает сценарий, когда продукт не найден. В этом случае изменение цены фактически вернет текущую цену продукта, что приведет к неправильному поведению. Это дефект.

Таким образом, даже несмотря на то, что покрытие кода указывает на очень высокое число (почти 100%), это ни в коем случае не является гарантией того, что код будет соответствовать бизнес-требованиям. Первая проблема очень очевидна, поскольку этого кода даже нет в производственном коде. Таким образом, инструмент покрытия кода не сможет измерить его влияние. Однако второй вопрос более тонкий. Это показатель того, как критические дефекты могут легко проскочить, даже когда покрытие кода было очень высоким. Следовательно, чрезвычайно важно, чтобы покрытие кода не использовалось исключительно для оценки качества кода, особенно разработчиками.

Еще одно важное наблюдение: инструмент покрытия кода будет сообщать о строке, которая должна быть закрыта, если она была затронута хотя бы один раз за все выполнение. Однако, как мы видим, 2 из наших методов не были протестированы на предмет отсутствия продукта, и, следовательно, строка, в которой DAO генерирует IllegalArgumentException, даже не попала в модульное тестирование для этих методов. Но это было сделано для модульных тестов получения сведений о продукте, и, следовательно, инструмент покрытия кода сообщит, что это покрыто. Хотя некоторые инструменты покрытия кода могут быть лучше других, разработчику, возможно, легко понять, что такие детали часто скрыты в деталях, и их нелегко найти. Дело в том, что тот факт, что строка покрыта отчетом о покрытии кода, не означает, что она покрыта для каждого соответствующего функционального сценария.

Итак, каково решение?

По моему опыту, сочетание хороших функциональных модульных тестов и покрытия кода является достаточно хорошим способом решения этой проблемы. Функциональный модульный тест написан с учетом бизнес-требований (как тот, который мы видели в нашем примере кода). У этого подхода есть много преимуществ.

  • Во-первых, вы перестаете думать о перестановках и комбинациях, которые легко упустить. Многие разработчики (в том числе некоторые из самых умных) сбиты с толку, когда дело доходит до модульного тестирования. «Что мне написать? Если метод принимает 3 параметра, мне следует просто передать null и их комбинации в свои модульные тесты ». Не то чтобы этот мыслительный процесс не важен. Но функциональные модульные тесты позволяют легко думать о том, как будет использоваться продукт, и, следовательно, какие положительные и отрицательные сценарии могут возникнуть.
  • Функциональные модульные тесты сосредоточены на реальных сценариях и гарантируют, что ваш код может правильно их обрабатывать. Следовательно, создавая их, вы не только делаете код более надежным, но и гарантируете, что вы не упустите ни одной критической функции.
  • Как разработчик, вы меняете фокус с достижения числа покрытия кода на обеспечение охвата как можно большего количества соответствующих функциональных сценариев.
  • Я твердо уверен, что писать функциональные модульные тесты намного веселее. Иногда вам нужно проявить немного творчества, как при написании тестов, так и придумывая сценарии.

Я уверен, что здесь кто-то может подумать: «А как насчет специалистов по контролю качества?» Разве они не охватят это в своих тестах? " Ответ: «Вполне вероятно, что они это сделают». Но, как разработчик, вы не хотите полагаться на то, что QA упадет, чтобы измерить его качество. Если бы вы могли более эффективно тестировать свой код, не стал бы код намного лучше и не сделал бы вас более сильным профессионалом? Кроме того, чем позже будут обнаружены дефекты, тем дороже их исправить. Итак, чем раньше вы сможете решить проблемы в жизненном цикле разработки, тем лучше. И когда вы объединяете эти функциональные модульные тесты с постоянно улучшающимся покрытием кода, вы получаете гораздо более надежный и надежный код, который отвечает потребностям бизнеса.

Другое чтение











Удачного кодирования!
- Nitin

Изначально опубликовано в Cloud Nine Apps.