Working with test smells

Code smell is a technical debt or symptom that indicates a deeper problem. Smells are not bugs, or they don't fail tests. Instead, they indicate a problem in design or code such that a rigid code cannot be enhanced or can create a maintenance issue. This section covers the test smells that should be refactored for maintenance and readability. The following topics are covered:

  • Test code duplication
  • Conditions in test code
  • Test logic in the production code
  • Over engineering

Refactoring duplicates

Code duplication is the simplest code smell. It creates maintainability problems. The same code is written in many places; if any bug is found, then you need to modify all other places. This subsection elaborates on the duplicate code in test cases.

Suppose you are designing a hospital management system and writing a test for checking a patient in. The following objects are needed for the patient check-in process: a person, a guarantor, reason for hospitalization, the attending physician, and the check-in date. A person should have an address. A guarantor can be a person or an organization, such as a jail authority, a government authority, or a corporate sponsor. A guarantor should have an address.

The following test snippet creates two Person objects for check in, a patient johnPeterson, and his guarantor johnsDad:

Person johnsDad = new Person();
   Address newYorkBayArea = new Address();
   newYorkBayArea.setAddressType(AddressType.Residential);
   newYorkBayArea.setCountry("US");
   newYorkBayArea.setState("NY");
   newYorkBayArea.setZip("49355");
   newYorkBayArea.setStreet("12/e xyz Avenue");
   johnsDad.addAddress(newYorkBayArea);
   johnsDad.setEmail("[email protected]");
   johnsDad.setFirstName("Freddy");
   johnsDad.setLastName("Peterson");
   daddy.setPerson(johnsDad);
    
   Person johnPeterson = new Person();
   Address mavernPhilly = new Address();
   mavernPhilly.setAddressType(AddressType.Residential);
   mavernPhilly.setCountry("US");
   mavernPhilly.setState("PA");
   mavernPhilly.setZip("19355");
   mavernPhilly.setStreet("123 Frazer");
   johnPeterson.addAddress(mavernPhilly);
   johnPeterson.setEmail("[email protected]");
   johnPeterson.setFirstName("John");
   johnPeterson.setLastName("Peterson");

Two Person objects and two Address objects are created and initialized. They are logically duplicate statements. Many other tests can write similar duplicate statements. Extract the method to refactor the duplicate smell. Extract the builder methods for the Person and Address objects as follows:

protected Person newPerson(Address newYorkBayArea, StringlastName, String email, String firstName) {
  Person person = new Person();
  person.addAddress(newYorkBayArea);
  person.setEmail(email);
  person.setFirstName(firstName);
  person.setLastName(lastName);
  return person;
}

protected Address newAddress(String street, String country, String state, String zip, AddressType residential) {
  Address address = new Address();
  address.setAddressType(residential);
  address.setCountry(country);
  address.setState(state);
  address.setZip(zip);
  address.setStreet(street);
  return address;
}

From the test code, just pass the required values and call the build methods as follows:

Address newYorkBayArea = newAddress("12/e xyz Avenue", "US", "NY","49355", AddressType.Residential);

Person johnsDad = newPerson(newYorkBayArea, "Peterson","[email protected]", "Freddy");
    
Address mavernPhilly = newAddress("123 Frazer", "US", "PA", "19355", AddressType.Residential);
    
Person johnPeterson = newPerson(mavernPhilly, "Peterson", "[email protected]", "John");

We can refactor the duplicate code in many test classes by moving the common code to a base test class or a helper class.

Refactoring the test control logic

Unit test code verifies the behavior of the code under test, and usually, no conditional logic is written to verify the code. However, when a test contains code that is executed based on some condition, it gets complicated for the reader. The test executes fine but creates a maintainability problem.

When we post JMS messages to a destination (such as the TIBCO Enterprise Messaging Service), internally, the JMS provider posts administrative messages such as message received, message sent, and message acknowledged. However, each message contains the same JMS message ID. If we create a message logger program to listen to the JMS events (including administrative events), and log all events to a database for an audit trail, then the logger will save many messages with the same JMS message ID.

The following is an example of the test control logic. The message is defined as follows:

public class Message {
  private String jmsMessageID;
  private String header;
  private Object payload;
  private int eventType;
}

The eventType variable indicates the administrative message type (received is 1, sent is 2, and acknowledged is 3).

The MessagingService interface is defined as follows:

public interface MessagingService {
  String publish(Object message);
  List<Message> retrieveByMessageId(String jmsMessageId);
}

We'll verify the logging capability as follows:

@RunWith(MockitoJUnitRunner.class)
public class MessagingServiceTest {
  MessagingService service = new MessagingServiceImpl();
  
  @Test
  public void logs_messages() throws Exception {
    String msgId = service.publish(new String("hello world"));
    for(Message msg:service.retrieveByMessageId(msgId)) {
      if(msg.getEventType() == 2) {
        assertEquals("hello world", msg.getPayload());
        break;
      }
    }
  }
}

The Test method loops through the messages, finds a message, and then verifies the payload. The test contains logic. Do we need another test for this test? This is confusing.

To refactor our test, you can move the logic to the code under test. The API should have a method to return a specific type of message. That way, we can check the message object directly instead of looping and checking.

Removing the test logic from the production code

Writing code for testability is a quality. Often, we put testing logic into the production code for unit testing, such as a new constructor or new method. To make the code testable, the tests require extra logic in production code to gain access to the code's internal state for testing configuration or result verification. Testing logic in production code is a smell, though it doesn't break the code under test but increases the complexity of the code, and this can create severe maintainability problems or system failure if anything gets misconfigured.

The testing logic is inserted into the production code under the following conditions:

  • Adding conditional logic to return a hardcoded value during testing. The code under test acts as a dynamic stub as shown in the following example:
    public final class EncounterManager {
      public boolean isHack = false;
      
      public boolean save(Map data) {
        if(isHack) {
          return true;
        }
        Encounter enc = new EncounterServiceImpl().checkIn(buildCheckinRqst(data));
        return enc != null;
      }
    }

    EncounterManager cannot be overridden as the class is declared as final; so, you cannot create a mock or fake object of this class. If your code under test needs to stub the save() behavior, then somehow you need to bypass the database call made in the EncounterServiceImpl method to persist the check-in data into a database. So, the save() method has an isHack conditional logic. This Boolean variable is added for testing purposes. From test, the Boolean variable isHack is set to true. If accidentally this variable is set to true, then encounters will not be created in production.

  • Additional code is written only for test execution, or private variables are exposed as public. The following is an example:
    public final class EncounterManager {
      private List<Encounter> retrieveEncounters() {
        if (encounters == null) {
          Patient patient = new Patient();
          patient.setPatientId(patientId);
          new EncounterServiceImpl().retreiveBy(patient);
        }
        return encounters;
      }
      
      public List<Encounter> encounters;
      public void setEncounters(List<Encounter> encounters) {
        this.encounters = encounters;
      }
    }

    The retrieveEncounters() method is a private method used for lazy instantiation of encounters List. However, for testing purposes, encounters List is exposed as public and a public setter method is used. From test, either the setter method is called with a hardcoded List or directly the encounters List is set. If encounters List is accidentally set in production, users will see the wrong data in the UI.

  • Mockito doesn't allow stubbing the equals() and hashcode() methods, as they should not be overridden unless the logic is comprehensible. Yet, often for testing, we override the equals() and hashcode() methods and perform testing logic or return the hardcoded value. This is very dangerous. In production, if we need to put the objects in a collection or need to perform an equality check, then the system behaves in a bizarre fashion. The following code snippet overrides the hashcode() and equals() methods:
    @Override
    public int hashCode() {
      return isHack ? HACKED_NUMBER : 0;
    }
    
    @Override
    public boolean equals(Object obj) {
      if (obj instanceof EncounterManager) {
        return isHack && ((EncounterManager) obj).isHack;
      }
      return false;
    }

The equals() method returns false in the production code and hashcode() returns 0. The EncounterManager class cannot be used in conjunction with the Java collection framework.

To refactor the production code, remove the final keyword, override the class in the test context, and return the hardcoded values. However, never ever touch the equals() and hashcode() methods for testing.

Refactoring over engineered tests

Tests are system documentation. They should tell the reader what is being executed. Often, we put too much documentation and make it more complex for the reader to understand the intention. Sometimes, we refactor the test and extract clean, meaningful methods, pass variables to the extracted methods, and from test just invoke the methods. Now the reader fails to understand the utility of the test case, and everything is carried out elsewhere.

The following test example demonstrates Test with less or no information:

@Test
public void checks_in_patient() throws Exception {
  createCheckInRequestForAPatientWithAGuarantor();
  checkInaPatient();
  assertResult();
}

The unit test calls three methods: createCheckInRequestForAPatientWithAGuarantor, checkInaPatient, and assertResult. From the test body, it is not possible to understand what is being tested, what data is created, and what is asserted. A test should configure data, call the actual method, and assert results.

The following is an example of a test with overly verbose documentation:

public void checks_in_patient() throws Exception {
  CheckInRequest request = new CheckInRequest();
  request.setCheckInDate(new Date());
  request.setDisease("Vomiting");
  request.setDoctor("Dr. Mike Hussey");

  String country = "US";
  String johnsStreetAddress = "123 Frazer";
  String johnsState = "PA";
  String johnsZipCode = "19355";
  Address johnsAddressMavernPhilly = buildAddress(johnsStreetAddress, country, johnsState, johnsZipCode,  AddressType.Residential);

  String johnsEmailId = "[email protected]";
  String johnsFirstName = "John";
  String familyName = "Peterson";
  
  Person johnPeterson = buildPerson(johnsAddressMavernPhilly, familyName,johnsEmailId, johnsFirstName);

  request.setPerson(johnPeterson);
  
  Guarantor daddy = new Guarantor();
  daddy.setGuarantorType(GuarantorType.Person);
  String dadsStreetAddress = "12/e xyz Avenue";
  String dadsState = "NY";
  String dadsZipCode = "49355";
  Address dadsAddressNYBayArea =buildAddress(dadsStreetAddress, country, dadsState,dadsZipCode, AddressType.Residential);
  String dadsEmail = "[email protected]";
  String dadsFirstName = "Freddy";
  Person johnsDad = buildPerson(dadsAddressNYBayArea, familyName,  dadsEmail, dadsFirstName);
  daddy.setPerson(johnsDad);
  request.setGuarantor(daddy);
}

The test builds two Person objects and two Address objects. Two builder methods are extracted for code reuse. For better documentation, variables are created and the hardcoded values are set and passed to the builder methods. These hardcoded variables make it tough to understand what is going on.

Instead of creating a custom builder method in test class, you can modify the main data class to follow the builder pattern and build the object in multiple steps. That way, we don't have to create hardcoded variables such as johnsStreetAddress, we can directly call the methods we need.

The Person class is modified; the setter methods return an instance of this as follows:

public Person setFirstName(String firstName) {
  this.firstName = firstName;
  return this;
}
  
public Person setLastName(String lastName) {
  this.lastName = lastName;
  return this;
}

From test, we can build the object easily. The following test example needs only an e-mail ID, first name, and phone number for testing, so it should not populate other values.

We can build the object in three steps, and we no longer need the hardcoded strings to document the behavior:

Person mark = new Person().setEmail("[email protected]").setFirstName("Mark").setPhoneNumber1("444-999-0090");
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset