Sonntag, 19. März 2017

A Short Introduction to Unit Testing

Why should we unit test

Writing tests before writing the actual algorithms helps in understanding the intended behaviour and potential edge cases. Another advantage is, that bugs might be found earlier, when it is easier (and thus cheaper) to fix them. Also unit tests immediately show if a refactoring or new feature breaks existing code.

Example

As example I implemented a complex number class. The class implements the basic algebraic operations (addition, subtraction, mutiplication and division). The class also implements toString and equals. For the unit tests JUnit will be used. Here is the skeleton for the ComplexNumber class with only equals being implemented:

package com.example.pfogeltech;

public class ComplexNumber {
 
    private double real;
    private double imaginary;
 
    public ComplexNumber(double real, double imaginary){
        this.real = real;
        this.imaginary = imaginary;
    }
 
    public void add(ComplexNumber other){
  
    }

    public void subtract(ComplexNumber other){
  
    }

    public void multiply(ComplexNumber other){
  
    }

    public void divide(ComplexNumber other){
  
    }
 
    @Override
    public String toString(){   
        return "";
    }
 
    @Override
    public boolean equals(Object o){
       boolean equal = false;
  
       if(o instanceof ComplexNumber){
           ComplexNumber other = (ComplexNumber)o;
           if(real == other.real && imaginary == other.imaginary){
               equal = true;
           }
       }
  
       return equal;
    }
 
}
The equals method is already implemented because the unit tests will use assertEquals to compare the results of the computations to what one would expect. JUnit uses annotations to tag tests (@Test), methods for setting up (@Before, @BeforeClass) and methods for cleaning up (@After, @AfterClass). The methods tagged with @Before and @After have a print statement to demonstrate that both methods will be called before/after every test. The test cases for the different algebraic operations compute a single result and compare it against the expected value with assertEquals. The test case for toString tests against multiple values because the formatting changes depending on the value of the complex number. Here is the code for the test cases:

package com.example.pfogeltech;

import static org.junit.Assert.*;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;

public class TestComplexNumber {

 @Before 
 public void setUp(){
  System.out.println("setting up next test");
 }
 
 @After
 public void tearDown(){
  System.out.println("clearing up after test");
 }
 
 @Test
 public void testAdd() {
  ComplexNumber a = new ComplexNumber(1,0);
  ComplexNumber b = new ComplexNumber(0,1);
  a.add(b);
  ComplexNumber expectedResult = new ComplexNumber(1,1);
  assertEquals(expectedResult, a);
 }

 @Test
 public void testSubtract() {
  ComplexNumber a = new ComplexNumber(1,0);
  ComplexNumber b = new ComplexNumber(0,1);
  a.subtract(b);
  ComplexNumber expectedResult = new ComplexNumber(1,-1);
  assertEquals(expectedResult, a);
 }
 
 @Test
 public void testMultiply(){
  ComplexNumber a = new ComplexNumber(0,1);
  ComplexNumber b = new ComplexNumber(0,1);
  a.multiply(b);  
  ComplexNumber expectedResult = new ComplexNumber(-1,0);
  assertEquals(expectedResult, a);
 }
 
 @Test
 public void testDivide(){
  ComplexNumber a = new ComplexNumber(1,1);
  ComplexNumber b = new ComplexNumber(1,1);
  a.divide(b);
  ComplexNumber expectedResult = new ComplexNumber(1,0);
  assertEquals(expectedResult, a);
 }
 
 @Test
 public void testToString(){  
  ComplexNumber real = new ComplexNumber(2,0);  
  ComplexNumber imaginary = new ComplexNumber(0,-2);  
  ComplexNumber mixedPositive = new ComplexNumber(3,14);
  ComplexNumber mixedNegative = new ComplexNumber(3,-14);
  
  assertEquals("2.0", real.toString());  
  assertEquals("-2.0i",imaginary.toString());
  assertEquals("3.0 + 14.0i",mixedPositive.toString());
  assertEquals("3.0 - 14.0i",mixedNegative.toString());
 } 
}

Running the tests will show that all five tests fail. With each successfully implemented method one of the tests will succeed. Here is the code for addition and subtraction:

public void add(ComplexNumber other){
  real += other.real;
  imaginary += other.imaginary; 
 }

 public void subtract(ComplexNumber other){
  real -= other.real;
  imaginary -= other.imaginary;
 }
These methods are pretty straightforward. Here is the code for the multiplication and division:

public void multiply(ComplexNumber other){
  double realResult = real * other.real - imaginary * other.imaginary;  
  imaginary = real*other.imaginary + other.real * imaginary;
  real = realResult;
 }

 public void divide(ComplexNumber other){
  double divisor = other.real*other.real + other.imaginary*other.imaginary;
  double realResult = (real*other.real + imaginary*other.imaginary) / divisor;
  imaginary = (imaginary*other.real - real*other.imaginary)/divisor;
  real = realResult;
 }
Since the new values for the real and imaginary parts depend on each other it is important to save one of the values into a temporary variable. Imagine we wrote the following code instead:

    public void multiply(ComplexNumber other){
        real = real * other.real - imaginary * other.imaginary;  
        imaginary = real*other.imaginary + other.real * imaginary;
    }
If we now compute the result for i * i using the code above we get -1 - i instead of the expected -1. Finally here is the code for the toString method:

@Override
 public String toString(){
  String value = "";
  if(real == 0.0 && imaginary != 0.0){
   value = String.valueOf(imaginary)+"i";
  }
  else if (real != 0.0 && imaginary == 0.0){
   value = String.valueOf(real);
  }
  else{
   if(imaginary > 0.0){
    value = String.valueOf(real) + " + " + String.valueOf(imaginary)+"i";
   }
   else{
    value = String.valueOf(real) + " - " + String.valueOf(Math.abs(imaginary))+"i";
   }
    
  }
  
  return value;
 }
This method checks if we only have a real (e.g. 0.0) or imaginary (e.g. -2i) part and then returns a suitable string representation. The special case where the imaginary part is either 1 or -1 is not handled. Also it may be a good idea to use a formatted string instead of the string concatenations.

Keine Kommentare:

Kommentar veröffentlichen