Статьи

Читаемые тесты: отделение цели от реализации

Совсем недавно я работал над таким классом:

public class AnalyticsExpirationDateManagerTest extends TestCase {
 
 private static final long ONE_HOUR_TIMEOUT = 1000 * 60 * 60;
 private static final long TWO_HOUR_TIMEOUT = ONE_HOUR_TIMEOUT * 2;
  
 private Map<Parameter, Long> analyticsToTimeout;
 private long defaultTimeout;
  
 private Parameter minTimeoutParam;
 @Mock private CacheKeyImpl<Parameter> cacheKey;
 
    @Override
    protected void setUp() throws Exception {
     MockitoAnnotations.initMocks(this);
      
     this.minTimeoutParam = new Parameter("minTimeout", "type");
      
     when(cacheKey.getFirstKey()).thenReturn(minTimeoutParam);
      
     this.analyticsToTimeout = new HashMap<Parameter, Long>();
     this.defaultTimeout = 0;
    }
  
 public void
 testGetExpirationDateWhenAnalyticsToTimeoutsAndCacheKeyAreEmpty() {
  AnalyticsExpirationDateManager<Long> manager =
    new AnalyticsExpirationDateManager<Long>(analyticsToTimeout, defaultTimeout);
  Date date = manager.getExpirationDate(cacheKey, 0L);
  assertNotNull(date);
 }
  
 public void
 testGetExpirationDateWithMinimunTimeoutOfOneHour() {
  this.analyticsToTimeout.put(this.minTimeoutParam, ONE_HOUR_TIMEOUT);
  Collection<Parameter> cacheKeysWithMinTimeoutParam = new ArrayList<Parameter>();
  cacheKeysWithMinTimeoutParam.add(this.minTimeoutParam);
  when(this.cacheKey.getKeys()).thenReturn(cacheKeysWithMinTimeoutParam);
   
  AnalyticsExpirationDateManager<Long> manager =
   new AnalyticsExpirationDateManager<Long>(analyticsToTimeout, defaultTimeout);
  Date date = manager.getExpirationDate(cacheKey, 0L);
 
  assertNotNull(date);
  Calendar expirationDate = Calendar.getInstance();
  expirationDate.setTime(date);
   
  Calendar currentDate = Calendar.getInstance();
   
  // Check if expiration date is one hour ahead current date.
  int expirationDateHour = expirationDate.get(Calendar.HOUR_OF_DAY);
  int currentDateHour = currentDate.get(Calendar.HOUR_OF_DAY);
  assertTrue(expirationDateHour - currentDateHour == 1);
 }
  
 public void
 testGetExpirationDateWhenCacheKeyIsNullAndDefaultTimeoutIsOneHour() {
  CacheKeyImpl<Parameter> NULL_CACHEKEY = null;
  AnalyticsExpirationDateManager<Long> manager =
   new AnalyticsExpirationDateManager<Long>(analyticsToTimeout, ONE_HOUR_TIMEOUT);
  Date date = manager.getExpirationDate(NULL_CACHEKEY, 0L);
   
  assertNotNull(date);
  Calendar expirationDate = Calendar.getInstance();
  expirationDate.setTime(date);
   
  Calendar currentDate = Calendar.getInstance();
   
  // Check if expiration date hour is the same of current date hour.
  // When cache key is null, system date and time is returned and default timeout is not used.
  int expirationDateHour = expirationDate.get(Calendar.HOUR_OF_DAY);
  int currentDateHour = currentDate.get(Calendar.HOUR_OF_DAY);
  assertTrue(expirationDateHour - currentDateHour == 0);
 }
  
 public void
 testGetExpirationDateWithDefaultTimeout() {
  // Default timeout is used when no time out is specified.
  Collection<Parameter> cacheKeysWithoutTimeoutParam = new ArrayList<Parameter>();
  cacheKeysWithoutTimeoutParam.add(new Parameter("name", "type"));
  when(this.cacheKey.getKeys()).thenReturn(cacheKeysWithoutTimeoutParam);
 
  AnalyticsExpirationDateManager<Long> manager =
   new AnalyticsExpirationDateManager<Long>(analyticsToTimeout, ONE_HOUR_TIMEOUT);
  Date date = manager.getExpirationDate(cacheKey, 0L);
   
  assertNotNull(date);
  Calendar expirationDate = Calendar.getInstance();
  expirationDate.setTime(date);
   
  Calendar currentDate = Calendar.getInstance();
   
  // Check if expiration date is one hour ahead current date.
  int expirationDateHour = expirationDate.get(Calendar.HOUR_OF_DAY);
  int currentDateHour = currentDate.get(Calendar.HOUR_OF_DAY);
  assertTrue(expirationDateHour - currentDateHour == 1);
 }
  
 public void
 testGetExpirationDateWhenMinTimeoutIsSetAfterCreation() {
  AnalyticsExpirationDateManager<Long> manager =
   new AnalyticsExpirationDateManager<Long>(analyticsToTimeout, ONE_HOUR_TIMEOUT);
  manager.setExpirationTimeout(this.minTimeoutParam.getName(), TWO_HOUR_TIMEOUT);
   
  Date date = manager.getExpirationDate(cacheKey, 0L);
   
  assertNotNull(date);
  Calendar expirationDate = Calendar.getInstance();
  expirationDate.setTime(date);
   
  Calendar currentDate = Calendar.getInstance();
   
  // Check if expiration date is two hour ahead current date.
  int expirationDateHour = expirationDate.get(Calendar.HOUR_OF_DAY);
  int currentDateHour = currentDate.get(Calendar.HOUR_OF_DAY);
  assertTrue("Error", expirationDateHour - currentDateHour == 2);
 }
  
}

Довольно страшно, не правда ли? Очень сложно понять, что там происходит.

Класс выше покрывает 100% тестируемого класса, и все тесты являются действительными тестами с точки зрения того, что тестируется.

Проблемы

Здесь довольно много проблем:
— Цель (что) и реализация (как) смешаны, что делает тесты очень трудными для чтения;
— среди методов испытаний довольно много дублирования;
— Существует также ошибка в методах тестирования при сравнении дат, когда пытается выяснить, на сколько часов одна дата опережает другую. При запуске этих тестов в середине дня они работают нормально. Если запустить их между 22:00 и 00:00, они сломаются. Причина в том, что расчет часа не учитывает день.

Повышение удобства чтения тестов

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

Я сделал довольно много рефакторингов для этого кода, чтобы сделать его более читабельным, всегда работая небольшими шагами и постоянно перезапуская тесты после каждого изменения. Я постараюсь обобщить мои шаги для ясности и краткости.

1. Исправление ошибки расчета часа

Одна из первых вещей, которые я должен был сделать, это исправить ошибку в подсчете часов. Чтобы исправить ошибку во всех тестовых методах, я решил выделить вычисление часов в отдельный класс, удалив все дубликаты из тестовых методов. Используя небольшие шаги, я воспользовался возможностью, чтобы сконструировать этот новый класс с именем DateComparator (да, я знаю, что я сосу классы именования), используя некоторые методы внутреннего DSL.

public class DateComparator {
  
 private Date origin;
 private Date target;
 private long milliseconds;
 private long unitsOfTime;
  
 private DateComparator(Date origin) {
  this.origin = origin;
 }
  
 public static DateComparator date(Date origin) {
  return new DateComparator(origin);
 }
  
 public DateComparator is(long unitsOfTime) {
  this.unitsOfTime = unitsOfTime;
  return this;
 }
  
 public DateComparator hoursAhead() {
  this.milliseconds = unitsOfTime * 60 * 60 * 1000;
  return this;
 }
  
 public static long hours(int hours) {
  return hoursInMillis(hours);
 }
  
 private static long hoursInMillis(int hours) {
  return hours * 60 * 60 * 1000;
 }
  
 public boolean from(Date date) {
  this.target = date;
  return this.checkDifference();
 }
  
 private boolean checkDifference() {
  return (origin.getTime() - target.getTime() >= this.milliseconds);
 }
}

So now, I can use it to replace the test logic in the test methods.

2. Extracting details into a super class

This step may seem a bit controversial at first, but can be an interesting approach for separating the what from how. The idea is to move tests set up, field declarations, initialisation logic, everything that is related to the test implementation (how) to a super class, leaving the test class just with the test methods (what).

Although this many not be a good OO application of the IS-A rule, I think this is a good compromise in order to achieve better readability in the test class.

NOTE: Logic can be moved to a super class, external classes (helpers, builders, etc) or both.

Here is the super class code:

public abstract class BaseTestForAnalyticsExperationDateManager extends TestCase {
 
 protected Parameter minTimeoutParam;
 @Mock protected CacheKeyImpl<Parameter> cacheKey;
 protected Date systemDate;
 protected CacheKeyImpl<Parameter> NULL_CACHEKEY = null;
 protected AnalyticsExpirationDateManager<Long> manager;
 
 @Override
 protected void setUp() throws Exception {
  MockitoAnnotations.initMocks(this);
  this.minTimeoutParam = new Parameter("minTimeout", "type");
  when(cacheKey.getFirstKey()).thenReturn(minTimeoutParam);
  this.systemDate = new Date();
 }
 
 protected void assertThat(boolean condition) {
  assertTrue(condition);
 }
  
 protected void addMinimunTimeoutToCache() {
  this.configureCacheResponse(this.minTimeoutParam);
 }
  
 protected void doNotIncludeMinimunTimeoutInCache() {
  this.configureCacheResponse(new Parameter("name", "type"));
 }
  
 private void configureCacheResponse(Parameter parameter) {
  Collection<Parameter> cacheKeysWithMinTimeoutParam = new ArrayList<Parameter>();
  cacheKeysWithMinTimeoutParam.add(parameter);
  when(this.cacheKey.getKeys()).thenReturn(cacheKeysWithMinTimeoutParam);
 }
}

 

3. Move creation and configuration of the object under test to a builder class

The construction and configuration of the AnalyticsExpirationDateManager is quite verbose and adds a lot of noise to the test. Once again I’ll be using a builder class in order to make the code more readable and segregate responsibilities. Here is the builder class:

public class AnalyticsExpirationDateManagerBuilder {
  
 protected static final long ONE_HOUR = 1000 * 60 * 60;
 
 protected Parameter minTimeoutParam;
 private AnalyticsExpirationDateManager<Long> manager;
 private Map<Parameter, Long> analyticsToTimeouts = new HashMap<Parameter, Long>();
 protected long defaultTimeout = 0;
 private Long expirationTimeout;
 private Long minimunTimeout;
 
 private AnalyticsExpirationDateManagerBuilder() {
  this.minTimeoutParam = new Parameter("minTimeout", "type");
 }
  
 public static AnalyticsExpirationDateManagerBuilder aExpirationDateManager() {
  return new AnalyticsExpirationDateManagerBuilder();
 }
  
 public static long hours(int quantity) {
  return quantity * ONE_HOUR;
 }
  
 public AnalyticsExpirationDateManagerBuilder withDefaultTimeout(long milliseconds) {
  this.defaultTimeout = milliseconds;
  return this;
 }
  
 public AnalyticsExpirationDateManagerBuilder withExpirationTimeout(long milliseconds) {
  this.expirationTimeout = new Long(milliseconds);
  return this;
 }
  
 public AnalyticsExpirationDateManagerBuilder withMinimunTimeout(long milliseconds) {
  this.minimunTimeout = new Long(milliseconds);
  return this;
 }
  
 public AnalyticsExpirationDateManager<Long> build() {
  if (this.minimunTimeout != null) {
   analyticsToTimeouts.put(minTimeoutParam, minimunTimeout);
  }
  this.manager = new AnalyticsExpirationDateManager<long>(analyticsToTimeouts, defaultTimeout);
  if (this.expirationTimeout != null) {
   this.manager.setExpirationTimeout(minTimeoutParam.getName(), expirationTimeout);
  }
  return this.manager;
 }
 
}

The final version of the test class

After many small steps, that’s how the test class looks like. I took the opportunity to rename the test methods as well.

import static com.mycompany.AnalyticsExpirationDateManagerBuilder.*;
import static com.mycompany.DateComparator.*;
 
public class AnalyticsExpirationDateManagerTest extends BaseTestForAnalyticsExperationDateManager {
 
 public void
 testExpirationTimeWithJustDefaultValues() {
  manager = aExpirationDateManager().build();
  Date cacheExpiration = manager.getExpirationDate(cacheKey, 0L);
  assertThat(dateOf(cacheExpiration).is(0).hoursAhead().from(systemDate));
 }
  
 public void
 testExpirationTimeWithMinimunTimeoutOfOneHour() {
     addMinimunTimeoutToCache(); 
  manager = aExpirationDateManager()
      .withMinimunTimeout(hours(1))
      .build();
  Date cacheExpiration = manager.getExpirationDate(cacheKey, 0L);
  assertThat(dateOf(cacheExpiration).is(1).hoursAhead().from(systemDate));
 }
  
 public void
 testExpirationTimeWhenCacheKeyIsNullAndDefaultTimeoutIsOneHour() {
  manager = aExpirationDateManager()
      .withDefaultTimeout(hours(1))
      .build();
  Date cacheExpiration = manager.getExpirationDate(NULL_CACHEKEY, 0L);
  // When cache key is null, system date and time is returned and default timeout is not used.
  assertThat(dateOf(cacheExpiration).is(0).hoursAhead().from(systemDate));
 }
  
 public void
 testExpirationTimeWithDefaultTimeout() {
  doNotIncludeMinimunTimeoutInCache();
  manager = aExpirationDateManager()
      .withDefaultTimeout(hours(1))
      .build();
  Date cacheExpiration = manager.getExpirationDate(cacheKey, 0L);
  assertThat(dateOf(cacheExpiration).is(1).hoursAhead().from(systemDate));
 }
  
 public void
 testExpirationTimeWhenExpirationTimeoutIsSet() {
  manager = aExpirationDateManager()
      .withDefaultTimeout(hours(1))
      .withExpirationTimeout(hours(2))
      .build();
  Date cacheExpiration = manager.getExpirationDate(cacheKey, 0L);
  // Expiration timeout has precedence over default timeout.
  assertThat(dateOf(cacheExpiration).is(2).hoursAhead().from(systemDate));
 }
  
}

Conclusion

Test classes should be easy to read. They should express intention, system behaviour, business rules. Test classes should express how the system works. They are executable requirements and specifications and should be a great source of information for any developer joining the project.

In order to achieve that, we need to try to keep our test methods divided in just 3 simple instructions.

1. Context: The state of the object being tested. Here is where we set all the attributes and mock dependencies. Using variations of the Builder pattern can greatly enhance readability.

	
manager = aExpirationDateManager()
                .withDefaultTimeout(hours(1))
                .withExpirationTimeout(hours(2))
                .build();

2. Операция : проверяемая операция. Вот где операция вызывается.

Date cacheExpiration = manager.getExpirationDate(cacheKey, 0L);

3. Утверждение . Здесь вы указываете ожидаемое поведение. Чем более читаема эта часть, тем лучше. Использование кода в стиле DSL, вероятно, является лучшим способом выразить цель теста.

assertThat(dateOf(cacheExpiration).is(2).hoursAhead().from(systemDate));

В этом посте я пошел задом наперед. Я начал с грязного тестового класса и реорганизовал его в более читаемую реализацию. Поскольку многие люди сейчас занимаются TDD, я хотел показать, как мы можем улучшить существующий тест. Для новых тестов я бы предложил начать писать тесты в соответствии с подходом «
Контекст >>
Операция >>
Утверждение» . Попробуйте написать тестовый код на простом английском языке. Как только цель теста станет ясной, начните заменять простой текст на английском языке внутренним кодом DSL Java, не допуская реализации вне класса тестирования.

PS: Идеи для этого поста блога возникли из нескольких дискуссий, которые у меня были во время встреч за круглым столом Software Craftsmanship, продвигаемых London Software Craftsmanship Community (LSCC).

From http://craftedsw.blogspot.com/2010/12/readable-tests-separating-intent-from.html