GitHub repository: Cartesian Plane Part 6
Previous lesson: Cartesian Plane Lesson 6: Unit Testing (Page 1)
Lesson 6: Unit Testing (Page 2)
1. Unit Testing LineGenerator
When I first started testing LineGenerator I ran into numerous small problems. These problems stemmed from issues like cumulative rounding errors in coordinate calculation, and misconceptions (or, perhaps, overly optimistic expectations) of the Java AWT.
The program CumulativeRoundingErrorDemo illustrates one of these problems. If I add up .99 10,000 times I should get 10,000 * .99 = 9900. But if I run a loop that literally performs 10,000 adds I actually wind up with the number 9901.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | public static void main(String[] args) { String fmt = "%8.2f %5d %8.2f %5d%n"; float next = 0; float incr = .99f; int repeat = 10000; for ( int inx = 0 ; inx <= repeat ; ++inx ) { float calc = inx * incr; int calcRounded = (int)(calc + .5); int nextRounded = (int)(next + .5); System.out.printf( fmt, next, nextRounded, calc, calcRounded ); next += incr; } } Tail of output: 9898.37 9898 9897.03 9897 9899.36 9899 9898.02 9898 9900.35 9900 9899.01 9899 9901.34 9901 9900.00 9900 |
Is this really a significant error in terms of what I am expecting to draw? No. However it could very well cause my tests to fail occasionally.
An example of another problem comes from the Java class Rectangle2D. If I have a rectangle that starts at x = 0 and has a width of 4 I would expect the center of the rectangle to be at x = 1.5; but Rectangle.getCenterX() puts it a x = 2.
In any event my first attempt at a test driver for LineGenerator started with a given rectangle and line spacing, and generated coordinates where I would expect to find lines in the Cartesian plane. I deliberately used a coordinate generation strategy that differed somewhat from the strategy used by the LineGenerator class (copying a block of code into a test driver is not the best way to validate the output of that block of code). What I found was that in a small number of cases my test driver's calculation differed from the LineGenerator's by a tiny bit.
These discrepancies are, in fact, so small that they can, in practice, be ignored. But how do you build an observation like that into a test driver? Can you say something like "fails in no more than 10 cases out of 1000 is acceptable"? Actually I can imagine scenarios in which this would be an acceptable strategy, but my intuition is that in this case we can do better. Let's call this problem 1:
Problem 1: calculations performed by the test driver differ slightly, though not necessarily significantly, from the calculations performed by the LineGenerator class.
The preceding discussion suggests another possible problem: is it possible that planar coordinates calculated by our program and then mapped into pixel coordinates by the AWT could result in unwanted drawing outside of the the grid? Honestly probably not, at least not in any significant way. Nevertheless considering this a potential problem leads us to an opportunity to discuss another aspect of graphics programming, so let's consider this a second problem to solve:
Problem 2: limit visible components of the grid to the inside of the grid's bounding rectangle.
Solving problem 2 is pretty straightforward, and a lot more fun than problem 1, so let's address it first.
Constraining the Effects of Drawing: Clip Regions
A clip region is an area of a plane, usually but not always rectangular in shape, that limits drawing to that area. We will implement in CartesianPlane a clip region that limits drawing to the rectangle bounding the planar grid. We can do this via setClip( Shape ) in the Graphics2D class.
Note: the documentation for Graphics2D.setClip( Shape ) says "Not all objects that implement the Shape interface can be used to set the clip. The only Shape objects that are guaranteed to be supported are Shape objects that are obtained via the getClip method and via Rectangle objects."
Our sandbox has two applications that demonstrate the appearance of a graphic before and after the clip region is set to a core rectangle (ClipRegionDemoBefore and ClipRegionDemoAfter). Here's the output of the two programs.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // Describe the rectangle containing the grid float gridWidth = currWidth - marginLeftWidth - marginRightWidth; float minXco = marginLeftWidth; float gridHeight = currHeight - marginTopWidth - marginBottomWidth; float minYco = marginTopWidth; gridRect = new Rectangle2D.Float( minXco, minYco, gridWidth, gridHeight ); // Set the clip region to the rectangle bounding the grid before // drawing any lines. Don't forget to restore the original clip // region after drawing the lines. Shape origClip = gtx.getClip(); gtx.setClip( gridRect ); drawGridLines(); drawMinorTics(); drawMajorTics(); drawAxes(); gtx.setClip( origClip ); |
Correctly Validating Calculations Performed by LineGenerator
Addressing this issue is another example of the rule if you can't find a good way to test your code consider changing your code. In this case we're not going to change a whole lot of code, but we are going to document in better detail how the line generation algorithm works. The first thing we'll do is refine the Javadoc for the LineGenerator class; refer to this excerpt from the LineGenerator Javadoc.
So we laid out some definitions and expectations. Note that we labeled the more prominent among them. This makes it easier to refer to specific items in discussions and related documents; referring to the list item numbers only works until we reorganize or amend the list items. One of the more important things we documented was where the items come from; in particular we noted that the bounding rectangle of the grid, grid unit and lines-per-unit are given by the user. The grid spacing and x-axis coordinates, on the other hand, are calculated by the algorithm according to the formulas given.
Some of the things about the calculations that we've changed (or at least made explicit) are:
- The coordinates of the x-axis are given by y = (rectangle-height - 1) / 2; the coordinates of the y-axis are given by x = (rectangle-width - 1) / 2 (formerly we used Rectangle2D.getCenterY() and Rectangle2D.getCenterX()).
- To determine the number of lines above the x-axis we use floor((rectHeight - 1) / 2 / gridSpacing); the number of lines below the x-axis is determined to be the same as the number of lines above.
- The number of lines left and right of the y-axis is determined similarly; the number of lines to the left is calculated, then the number of lines to the right is determined to be the same as the number of lines to the left.
- The coordinates of a line relative to the x-axis is determined to be a product of the line number and the grid spacing; no longer can we use (coordinates of line n + 1) = (coordinates of line n) + grid spacing. A similar principal applies to the lines relative to the y-axis.
- To calculate the left endpoint of a line segment parallel to the x-axis use the x-coordinate of the y-axis minus the length of the line segment divided by two; the right endpoint is determined by the left endpoint plus the length of the line segment.
- Similarly, the upper endpoint of a line segment parallel to the y-axis is the y-coordinate of the x-axis minus half the length of the line segment; the lower coordinate is then determined by the upper endpoint plus the length of the line segment.
Here's the main constructor for LineGenerator with the line generation rules incorporated.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | public LineGenerator( Rectangle2D rect, float gridUnit, float lpu, float length, int orientation ) { gridWidth = (float)rect.getWidth(); gridHeight = (float)rect.getHeight(); minXco = (float)rect.getX(); maxXco = minXco + gridWidth; // See description of line generation algorithm, above. // [rule: yAxis] centerXco = (float)rect.getX() + (gridWidth - 1) / 2; minYco = (float)rect.getY(); maxYco = minYco + gridHeight; // See description of line generation algorithm, above. // [rule: xAxis] centerYco = (float)rect.getY() + (gridHeight - 1) / 2; this.length = length; this.orientation = orientation; // See description of line generation algorithm, above. // [rule: gridSpacing] gridSpacing = gridUnit / lpu; // See description of line generation algorithm, above. // [rule: numHLinesAbove] // [rule: numVLinesLeft] // [rule: numHLinesTotal] // [rule: numVLinesTotal] float halfVerLines = (float)Math.floor((gridWidth - 1) / 2 / gridSpacing); totalVerLines = 2 * halfVerLines + 1; float halfHorLines = (float)Math.floor((gridHeight - 1) / 2 / gridSpacing); totalHorLines = 2 * halfHorLines + 1; } |
Following is the updated vertical line iterator. The horizontal line iterator is similar and can be found in the GitHub repository.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 | private class Line2DVerticalIterator implements Iterator<Line2D> { /** The number of lines drawn to either side of the y-axis. */ private final float halfNum; /** The upper y-coordinate of every vertical line. */ private final float yco1; /** The lower y-coordinate of every vertical line. */ private final float yco2; /** * The number of the next line to be generated; * range is [-halfNum, halfNum]. */ private float next; /** * Constructor. * Establishes the first line to draw * in the sequence of generated lines. * This is the left-most vertical line. * Subsequent lines will be generated sequentially * proceeding to the right. */ public Line2DVerticalIterator() { // Number of lines left or right of y-axis // See description of line generation algorithm, above. // [rule: numVLinesLeft] halfNum = (float)Math.floor( totalVerLines / 2 ); // Calculate the number of the first vertical line to draw; // note that this will be to the left of the y-axis. next = -halfNum; // Determine the y-coordinates of each vertical line; a length < 0 // means the line spans the height of the bounding rectangle if ( length < 0 ) { yco1 = minYco; yco2 = maxYco; } else { // Calculate the top (yco1) and bottom (yco2) of the line // See description of line generation algorithm, above. // [rule: vLineSegmentYco1] // [rule: vLineSegmentYco2] yco1 = centerYco - length / 2; yco2 = yco1 + length; } } @Override /** * Indicates whether or not this iterator has been exhausted. * This method required by "implements Iterator<Line2D>". * * @return true if this iterator has not been exhausted */ public boolean hasNext() { // The iterator is exhausted after drawing // the last line to the right of the y-axis. boolean hasNext = next <= halfNum; return hasNext; } @Override /** * Returns the next * in the sequence of lines generated * from the left of the grid to the right. * This method required by "implements Iterator<Line2D>". * * @return next in the sequence of generated lines * * @throws NoSuchElementException * if the iterator has been exhausted */ public Line2D next() { // Throw an exception if there is no next line. if ( next > halfNum ) { String msg = "Grid bounds exceeded at next = " + next; throw new NoSuchElementException( msg ); } // Calculate the x-coordinate of the next vertical line. // See description of line generation algorithm, above. // [rule: nthVLineLeft] // [rule: nthVLineRight] float xco = centerXco + next++ * gridSpacing; Line2D line = new Line2D.Float( xco, yco1, xco, yco2 ); return line; } } |
So now we're going to write a test driver that uses the same algorithm; practically the same code; as the class being tested. Let's put together a simple base case to show that the algorithm is sound. We'll choose parameters for the base case that make it easy to calculate expected values by hand.
Line Generation Algorithm: Base Case
For our parameters let's choose the following givens:
- Bounding rectangle (x, y) coordinates: (0, 0)
- Bounding rectangle width: 511
- Bounding rectangle height: 211
- Grid unit:50
- Lines-per-unit: 2
Now we can calculate the following values:
- [rule: gridSpacing] gridSpacing = 50 / 2 =25
- [rule: xAxis] x-axis y-coordinate = (211 - 1) / 2 =105
- [rule: yAxis] y-axis x-coordinate = (511 - 1) / 2 = 255
- [rule: numHLinesAbove] lines above x-axis = floor( (211 - 1) / 2 / 25) = 4
- [rule: numVLinesAbove] lines left of y-axis = floor( (511 - 1) / 2 / 25) = 10
- Horizontal lines at y =:
- -4 * 25 + 105 = 5.0
- -3 * 25 + 105 = 30.0
- -2 * 25 + 105 = 55.0
- -1 * 25 + 105 = 80.0
- 0 * 25 + 105 = 105 (x-axis)
- 1 * 25 + 105 = 130.0
- 2 * 25 + 105 = 155.0
- 3 * 25 + 105 = 180.0
- 4 * 25 + 105 = 205.0
- Vertical lines at x =:
- -10 * 25 + 255 = 5.0
- -9 * 25 + 255 = 30.0
- ...
- 0 * 25 + 255 = 255 (y-axis)
- ...
- 9 * 25 + 255 = 480.0
- 10 * 25 + 255 = 505.0
We will want to use these parameters (bounding rectangle, grid unit, LPU) in more one place, so, of course, we will encapsulate them; let's put them in class BaseCaseParameters in the test util package:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | package util; /** * This class collects the parameters * that describe the <em>base case</em> line generation example * in a single place. * * @author Jack Straub */ public class BaseCaseParameters { /** Width of the rectangle bounding the base case grid. */ public static final float BASE_GRID_WIDTH = 511; /** Height of the rectangle bounding the base case grid. */ public static final float BASE_GRID_HEIGHT = 211; /** Grid unit of the base case grid. */ public static final float BASE_GRID_UNIT = 50; /** LPU of the base case grid. */ public static final float BASE_LINES_PER_UNIT = 2; /** * Makes the default constructor private * in order to prevent instantiation. */ private BaseCaseParameters() { } } |
Let's have a look at what our Cartesian plane looks like with the base case parameters applied; here's a program from the sandbox package on the test branch of the source tree, and a picture of what it produces:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | package com.acmemail.judah.cartesian_plane.sandbox; import static util.BaseCaseParameters.BASE_GRID_HEIGHT; import static util.BaseCaseParameters.BASE_GRID_UNIT; import static util.BaseCaseParameters.BASE_GRID_WIDTH; import static util.BaseCaseParameters.BASE_LINES_PER_UNIT; import javax.swing.JPanel; import com.acmemail.judah.cartesian_plane.CPConstants; import com.acmemail.judah.cartesian_plane.CartesianPlane; import com.acmemail.judah.cartesian_plane.graphics_utils.Root; /** * Displays a grid representing the <em>base case</em>, * as determined by the figures in {@linkplain BaseCaseParameters}. * * @author Jack Straub */ @SuppressWarnings("serial") public class BaseCase extends JPanel { private static final float marginTopWidth = CPConstants.asFloat( CPConstants.MARGIN_TOP_WIDTH_DV ); private static final float marginRightWidth = CPConstants.asFloat( CPConstants.MARGIN_RIGHT_WIDTH_DV ); private static final float marginBottomWidth = CPConstants.asFloat( CPConstants.MARGIN_BOTTOM_WIDTH_DV ); private static final float marginLeftWidth = CPConstants.asFloat( CPConstants.MARGIN_LEFT_WIDTH_DV ); // Calculate the dimensions of the window needed if we want // the grid to be BASE_GRID_WIDTH x BASE_GRID_HEIGHT private static final int windowWidth = (int)(BASE_GRID_WIDTH + marginLeftWidth + marginRightWidth); private static final int windowHeight = (int)(BASE_GRID_HEIGHT + marginTopWidth + marginBottomWidth); /** * Application entry point. * * @param args command line arguments; not used */ public static void main( String[] args ) { CartesianPlane canvas = new CartesianPlane( windowWidth, windowHeight ); Root root = new Root( canvas ); canvas.setGridUnit( BASE_GRID_UNIT ); canvas.setGridLineLPU( BASE_LINES_PER_UNIT ); root.start(); } } |
To support testing (especially since we'll have at least two test classes for the LineGenerator class), I've added a new utilities class, LineMetrics, to the utils package on the test source tree. We'll have more to say about that shortly, but for now here is the assertLineEquals method, which we need for the base case unit test.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | /** * Verify that two lines have the same end points, * within a given tolerance. * * @param expLine the expected line * @param actLine the actual line * @param epsilon the given tolerance */ public static void assertLineEquals( Line2D expLine, Line2D actLine, float epsilon ) { float expLineXco1 = (float)expLine.getX1(); float expLineYco1 = (float)expLine.getY1(); float expLineXco2 = (float)expLine.getX2(); float expLineYco2 = (float)expLine.getY2(); float actLineXco1 = (float)actLine.getX1(); float actLineYco1 = (float)actLine.getY1(); float actLineXco2 = (float)actLine.getX2(); float actLineYco2 = (float)actLine.getY2(); String fmt = "Expected: Line2D(%.1f,%.1f,%.1f,%.1f) " + "Actual: Line2D(%.1f,%.1f,%.1f,%.1f)"; String msg = String.format( fmt, expLineXco1, expLineYco1, expLineXco2, expLineYco2, actLineXco1, actLineYco1, actLineXco2, actLineYco2 ); assertEquals( expLineXco1, actLineXco1, epsilon, msg ); assertEquals( expLineYco1, actLineYco1, epsilon, msg ); assertEquals( expLineXco2, actLineXco2, epsilon, msg ); assertEquals( expLineYco2, actLineYco2, epsilon, msg ); } |
We've used a new overload for the assertEquals method; this one takes a string as the last argument. Most of the JUnit assert methods have an overload like this; if the assertion fails the string is displayed in the status window along with the failure trace. The message from this method would look something like this:
org.opentest4j.AssertionFailedError: Expected: Line2D(5.0,0.0,5.0,211.0) Actual: Line2D(6.0,0.0,5.0,211.0) ==> expected: <5.0> but was: <6.0>
Now we have a JUnit test just for the base case. Notice we used @BeforeAll, designating a method to be executed once before any test method. @BeforeAll methods must be class methods (declared static).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 | public class LineGeneratorBaseCaseTest { /** Tolerance for testing the approx. equality of 2 decimal numbers. */ private static final float epsilon = .001f; private static final float gridXco = 0; private static final float gridYco = 0; private static final float gridWidth = BASE_GRID_WIDTH; private static final float gridHeight = BASE_GRID_HEIGHT; private static final float gridUnit = BASE_GRID_UNIT; private static final float gridLPU = BASE_LINES_PER_UNIT; private static final Rectangle2D gridRect = new Rectangle2D.Float( gridXco, gridYco, gridWidth, gridHeight ); /** List of all horizontal lines, in order. Initialized in beforAll(). */ private static final List<Line2D> horizLines = new ArrayList<>(); /** List of all horizontal lines, in order. Initialized in beforAll(). */ private static final List<Line2D> vertLines = new ArrayList<>(); /** * Calculate all the horizontal and vertical lines * expected to be generated * given the width, height, gridUnit and lpu * specified above. * The upper left-hand corner of the grid * is assumed to be (0,0). */ @BeforeAll public static void beforeAll() { // All of the following calculations assume that the coordinates // of the bounding rectangle are (0, 0) // grid spacing; pixels-per-line [rule: gridSpacing] float gridSpacing = gridUnit / gridLPU; // Horizontal lines in half the grid (not including x-axis) // [rule: numHLinesAbove] int halfHoriz = (int)Math.floor( (gridHeight - 1) / 2 / gridSpacing ); // Vertical lines in half the grid (not including y-axis) // [rule: numVLinesLeft] int halfVert = (int)Math.floor( (gridWidth - 1) / 2 / gridSpacing ); // Y-coordinate of the x-axis [rule: xAxis] float xAxisYco = (gridHeight - 1) / 2; // X-coordinate of the y-axis [rule: yAxis] float yAxisXco = (gridWidth - 1) / 2; // Calculate all horizontal lines //[rule: nthHLineAbove], [rule: nthHLineAbove] for ( int inx = -halfHoriz ; inx <= halfHoriz ; ++inx ) { float yco = xAxisYco + inx * gridSpacing; Line2D line = new Line2D.Float( 0, yco, gridWidth, yco ); horizLines.add( line ); } // Calculate all the vertical lines //[rule: nthVLineLeft], [rule: nthVLineRight] for ( int inx = -halfVert ; inx <= halfVert ; ++inx ) { float xco = yAxisXco + inx * gridSpacing; Line2D line = new Line2D.Float( xco, 0, xco, gridHeight ); vertLines.add( line ); } } // ... } |
Here's the unit test for horizontal line generation for the base case; the test for vertical line generation is similar and can be found in the GitHub repository.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | @Test public void testLineGeneratorHoriz() { LineGenerator gen = new LineGenerator( gridRect, gridUnit, gridLPU, -1, LineGenerator.HORIZONTAL ); int expNumHorizLines = horizLines.size(); int actNumHorizLines = (int)gen.getTotalHorizontalLines(); assertEquals( expNumHorizLines, actNumHorizLines ); int count = 0; for ( Line2D actLine : gen ) { Line2D expLine = horizLines.get( count++ ); LineMetrics.assertLineEquals( expLine, actLine, epsilon ); } assertEquals( count, expNumHorizLines ); } |
The LineMetrics Class
LineMetrics is a class that we've added to the utils package on the test source branch. It encapsulates the line generation algorithm. Its main purpose is to generate the expected locations of the horizontal and vertical line for a given grid's bounding rectangle. Most of the work is done by the constructor which performs almost all of the anticipated calculations and stores the results in instance variables.
Note 1: all the instance variables have been explicitly declared private. Don't forget to explicitly declare the access level of an identifier, even if its only being used in a test environment.
Note 2: all the instance variables have been declared final. It's a good idea to make instance and class variables final whenever possible.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | public class LineMetrics { /** List of horizontal lines generated by the LineGenerator algorithm. */ private final List<Line2D> hLines = new ArrayList<>(); /** List of Vertical lines generated by the LineGenerator algorithm. */ private final List<Line2D> vLines = new ArrayList<>(); /** * Rectangle bounding the grid; * see LineGenerator <b>[rule: boundingRect</b>]. */ private final Rectangle2D boundingRect; /** * Pixels allocated per unit; * see LineGenerator <b>[rule: gridUnit]</b>. */ private final float gridUnit; /** * Grid lines per unit; * see LineGenerator <b>[rule: lpu]</b>. */ private final float lpu; /** * Spacing between lines; * see LineGenerator <b>[rule: gridSpacing]</b>. */ private final float gridSpacing; /** * X-coordinate of the y-axis; * see LineGenerator <b>[rule: yAxis]</b>. */ private final float yAxisXco; /** * Y-coordinate of the x-axis; * see LineGenerator <b>[rule: xAxis]</b>. */ private final float xAxisYco; /** * Constructor. * Initializes all parameters. * * @param rect bounding rectangle for grid * @param gridUnit pixels per unit * @param linesPerUnit lines-per-unit */ public LineMetrics( Rectangle2D rect, float gridUnit, float linesPerUnit ) { this.boundingRect = rect; this.gridUnit = gridUnit; this.lpu = linesPerUnit; // see LineGenerator rule: gridSpacing // pixels between lines this.gridSpacing = gridUnit / linesPerUnit; float minXco = (float)rect.getX(); float maxXco = (float)rect.getWidth() + minXco; float minYco = (float)rect.getY(); float maxYco = (float)rect.getHeight() + minYco; // see LineGenerator rule: yAxis // determines location of y-axis yAxisXco = minXco +((float)rect.getWidth() - 1) / 2; // determines location of x-axis xAxisYco = minYco + ((float)rect.getHeight() - 1) / 2; // see LineGenerator rule: numHLinesAbove // see LineGenerator rule: numHLinesBelow // horizontal lines above or below x-axis float halfHoriz = (float)Math.floor( (rect.getHeight() - 1) / 2 / gridSpacing); // see LineGenerator rule: numVLinesLeft // see LineGenerator rule: numVLinesRight float halfVert = (float)Math.floor( (rect.getWidth() - 1) / 2 / gridSpacing); // see LineGenerator rule: nthVLineLeft // see LineGenerator rule: nthVLineRight // generate vertical lines for ( float nextVert = -halfVert ; nextVert <= halfVert ; ++nextVert ) { float xco = yAxisXco + nextVert * gridSpacing; Line2D line = new Line2D.Float( xco, minYco, xco, maxYco ); vLines.add( line ); } // see LineGenerator rule: nthHLineAbove // see LineGenerator rule: nthHLineBelow // generate horizontal lines for ( float nextHoriz = -halfHoriz ; nextHoriz <= halfHoriz ; ++nextHoriz ) { float yco = xAxisYco + nextHoriz * gridSpacing; Line2D line = new Line2D.Float( minXco, yco, maxXco, yco ); hLines.add( line ); } } / ... } |
Most of the rest if the class consists of accessors; the assertLineEquals convenience method which we looked at, above; and getLineSegment. Given the coordinates of a vertical or horizontal line, and a length, this method calculates the coordinates of a line segment that intersects the x- or y-axis at its midpoint (which is what we want, for example, when drawing a tic mark). Here's the code for getLineSegment; the complete code for this class can be found in the GitHub repository.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | /** * Get the line segment associated * with a given vertical or horizontal line * of a given length. * * @param lineIn the given horizontal or vertical line * @param len the given length * * @return the line segment with the given length */ public Line2D getLineSegment( Line2D lineIn, float len ) { Line2D lineOut; float lineInXco1 = (float)lineIn.getX1(); float lineInXco2 = (float)lineIn.getX2(); if ( floatEquals( lineInXco1, lineInXco2 ) ) { // if x1 == x2 this is a vertical line float xco = lineInXco1; float yco1 = xAxisYco - len / 2; float yco2 = yco1 + len; lineOut = new Line2D.Float( xco, yco1, xco, yco2 ); } else { // x1 != x2 this is not a vertical line, // it must be a horizontal line float yco = (float)lineIn.getY1(); float xco1 = yAxisXco - len / 2; float xco2 = xco1 + len; lineOut = new Line2D.Float( xco1, yco, xco2, yco ); } return lineOut; } |
The LineGenerator JUnit Test
The LineGeneratorTest JUnit test class starts with two class variables: epsilon for testing decimal values for approximate equality, and a random number generator from the java.utils class:
import java.util.Random;
// ...
/** For testing decimal values for equality. */
private static final float epsilon = .001f;
/**
* Random number generator.
* Instantiated with a value
* that guarantees it will
* always generate the same sequence of values.
*/
private static final Random randy = new Random( 5 );
Note that we instaniate Random with an integer value. This is called a seed. For any given seed a random number generator will generate the same sequence of numbers each time it is started. This may seem counter-intuitive for a "random" number generator, but it's usually what you want. When investigating a complex algorithm you may find that it fails on the 1000th iteration of some test. To determine why the test fails you will want to be able to reproduce the same initial sequence of events that led to the failure. If you want to look at test cases with different sequences of initial events you can run the test multiple times using different seed values. And you can always use the default constructor for Random, which uses a different seed each time.
The random number generator will be executed before each test in order to generate values for the three givens to the line generation algorithm: the bounding rectangle (x- and y-coordinates, width and height), grid unit and LPU. The generated values are restricted to a "reasonable" range for each property. There are minimum and maximum values for each property which have been chosen to meet certain criteria, such as ensuring that a minimum number of lines are generated for each test, and that the LPU property does not exceed the grid unit. The range of each property value has been specified via constants declared at the top of the class.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | /** Minimum x-coordinate for the origin of the bounding rectangle. */ private static final float minRectXco = 0; /** Maximum x-coordinate for the origin of the bounding rectangle. */ private static final float maxRectXco = 100; /** Minimum y-coordinate for the origin of the bounding rectangle. */ private static final float minRectYco = 0; /** Maximum y-coordinate for the origin of the bounding rectangle. */ private static final float maxRectYco = 100; /** * Minimum width of the test grid's bounding rectangle. * @see <a href="#TestValueCoordination>Test Value Coordination</a> */ private static final float minRectWidth = 300; /** * Maximum width of the test grid's bounding rectangle. * The choice of this value must be coordinated the * maxGridUnit value to ensure compliance * with the "Test Value Coordination" constraints, above. * @see <a href="#TestValueCoordination>Test Value Coordination</a> * @see #maxGridUnit */ private static final float maxRectWidth = 3000; /** * Minimum height of the test grid's bounding rectangle. * The choice of this value must be coordinated the * maxGridUnit value to ensure compliance * with the "Test Value Coordination" constraints, above. * * @see <a href="#TestValueCoordination>Test Value Coordination</a> */ private static final float minRectHeight = 300; /** * Maximum height of the test grid's bounding rectangle. * @see <a href="#TestValueCoordination>Test Value Coordination</a> */ private static final float maxRectHeight = 3000; /** * Minimum grid unit (pixels-per-unit). * @see <a href="#TestValueCoordination>Test Value Coordination</a> */ private static final float minGridUnit = 5; /** * Maximum grid unit (pixels-per-unit). * The choice of this value must be coordinated the * minRectWidth and maxRectWidth values to ensure compliance * with the "Test Value Coordination" constraints, above. * @see <a href="#TestValueCoordination>Test Value Coordination</a> * @see #minRectWidth * @see #maxRectWidth */ private static final float maxGridUnit = 50; /** * Minimum LPU (lines-per-unit). * Note that the maximum LPU * is chosen at run time. * @see <a href="#TestValueCoordination>Test Value Coordination</a> */ private static final float minLPU = 1; // Note: the maximum value for the LPU is determined by // the calculated grid spacing. |
Calculating a random value within a given range is not difficult, but it is messy enough that I would like to remove it to a helper method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | /** * Generate the next float value * within a given range * as determined by the Random instance for this test. * * @param min the beginning of the range * @param max the end of the range * * @return the next float value within the given range * * @see #randy */ private static float nextFloat( float min, float max ) { float diff = max - min; float next = randy.nextFloat(); next = next * diff + min; // validate result assertTrue( next >= min ); assertTrue( next < max ); return next; } |
The calculations will be performed before each test by a @BeforeEach method, and the results stored in instance variables for the convenience of the individual test methods. Note that the calculations include instatiation of a LineMetrics object, based on the three generated givens.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | ///////////////////////////////////////////////////////////////////// // TEST VALUES CHOSEN BEFORE THE START OF EACH TEST. // These have been implemented as instance variables for the // convenience of the individual tests. // See beforeEach. ///////////////////////////////////////////////////////////////////// private float defRectXco; private float defRectYco; private float defRectWidth; private float defRectHeight; private Rectangle2D defRect; private float defLPU; private float defGridUnit; private LineMetrics defMetrics; ///////////////////////////////////////////////////////////////////// // END TEST VALUES CHOSEN BEFORE THE START OF EACH TEST. ///////////////////////////////////////////////////////////////////// /** * Initialization logic executed immediately before * the start of each test. */ @BeforeEach public void beforeEach() { defRectXco = nextFloat( minRectXco, maxRectXco ); defRectYco = nextFloat( minRectYco, maxRectYco ); defRectWidth = nextFloat( minRectWidth, maxRectWidth ); defRectHeight = nextFloat( minRectHeight, maxRectHeight ); defRect = new Rectangle2D.Float( defRectXco, defRectYco, defRectWidth, defRectHeight ); // Don't generate more lines-per-unit than pixels-per-unit. defGridUnit = nextFloat( minGridUnit, maxGridUnit ); defLPU = nextFloat( minLPU, defGridUnit ); defMetrics = new LineMetrics( defRect, defGridUnit, defLPU ); // Validation of generated test values: // Make sure generated values conform to required constraints. // See "Test Value Coordination" constraints, above. assertTrue( minRectWidth > 10 ); assertTrue( defRectWidth >= minRectWidth ); assertTrue( minRectHeight > 10 ); assertTrue( defRectHeight >= minRectHeight ); float expGridSpacing = defGridUnit / defLPU; assertTrue( expGridSpacing >= 1 ); assertTrue( defMetrics.getNumHorizLines() >= 3 ); assertTrue( defMetrics.getNumVertLines() >= 3 ); // for thorough validation of LineMetrics assertEquals( defRect, defMetrics.getRect() ); assertEquals( defGridUnit, defMetrics.getGridSpacing() ); assertEquals( defLPU, defMetrics.getLPU() ); } |
For our first test method let's see how well the "big" constructor performs (the constructor that doesn't allow any default values). We'll pass it an explicit line length, and tell it to generate just horizontal lines. Then what should we test for? I propose that we limit testing to properties associated mainly with the constructor, namely that, after instantiation:
- It generates at least one line;
- It generates lines of the given length; and
- It generates only horizontal lines.
The code for the test method is shown below.
About the test method name: initially we let Eclipse generate the name of the test method for us. The name always starts with test followed by the name of the method being tested, in this case the name of the constructor, LineGenerator. This is followed by the types of the parameters (which distinguishes between test methods for different overloads), Rectangle2D, Float, Float, Float, Int. For the name of our first method we added an H, for horizontal, giving us testLineGeneratorRectangle2DFloatFloatFloatIntH. Our next test method will be testLineGeneratorRectangle2DFloatFloatFloatIntV, where the V is for vertical.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | @Test public void testLineGeneratorRectangle2DFloatFloatFloatIntH() { float expLen = 5; LineGenerator gen = new LineGenerator( defRect, defGridUnit, defLPU, expLen, LineGenerator.HORIZONTAL ); // do we get at least one line? Iterator<Line2D> iter = gen.iterator(); assertTrue( iter.hasNext() ); Line2D line = iter.next(); float xco1 = (float)line.getX1(); float yco1 = (float)line.getY1(); float xco2 = (float)line.getX2(); float yco2 = (float)line.getY2(); // is it a horizontal line? assertEquals( yco1, yco2 ); // is the line the correct length? float actLen = xco2 - xco1; assertEquals( expLen, actLen ); // are only horizontal lines generated? while ( iter.hasNext() ) { Line2D next = iter.next(); assertEquals( next.getY1(), next.getY2() ); } } |
Testing the "big" constructor with vertical lines is similar; the associated test method can be found in the GitHub repository. Testing the constructor that allows line length and orientation to default (to -1 and BOTH) is a little different, and takes advantage of a helper method. Notice that the helper method goes to the trouble of thoroughly describing any failure.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 | /** * Test the constructor that allows line length and orientation to default. * <p> * Not too concerned with checking line generation detail here; * that will come later. * Right now I just want to make sure * that the constructors are performing * the correct initialization. * </p> * * case orientation = BOTH */ @Test public void testLineGeneratorRectangle2DFloatFloatFloatIntB() { LineGenerator gen = new LineGenerator( defRect, defGridUnit, defLPU ); // has horizontal and vertical lines? assertImplementsBoth( gen ); float expLen; float actLen; // test expected length; depending on line orientation // it should be either the width or the height of the // bounding rectangle. Line2D line = gen.iterator().next(); float xco1 = (float)line.getX1(); float xco2 = (float)line.getX2(); float yco1 = (float)line.getY1(); float yco2 = (float)line.getY2(); if ( xco2 > xco1 ) { expLen = defRectWidth; actLen = xco2 - xco1; } else { expLen = defRectHeight; actLen = yco2 - yco1; } assertEquals( expLen, actLen, epsilon ); } // ... /** * Verify that a given LineGenerator * generates both horizontal and vertical lines. * @param gen */ private void assertImplementsBoth( LineGenerator gen ) { // did we get the expected number of lines? float expNumLines = defMetrics.getNumVertLines() + defMetrics.getNumHorizLines(); float actNumLines = gen.getTotalVerticalLines() + gen.getTotalHorizontalLines(); assertEquals( expNumLines, actNumLines ); boolean hasHorizontal = false; boolean hasVertical = false; for ( Line2D line : gen ) { if ( line.getY1() == line.getY2() ) hasHorizontal = true; if ( line.getX1() == line.getX2() ) hasVertical = true; } // verify horizontal and vertical lines returned if ( !hasHorizontal || !hasVertical ) { String msg = "Expected BOTH; actual: "; if ( !hasHorizontal ) msg += "NOT "; msg += "HORIZONTAL, "; if ( !hasVertical ) msg += "NOT "; msg += "VERTICAL, "; fail( msg ); } } |
The get-total-horizontal/vertical-lines accessors are pretty straightforward and give us the chance to learn a new JUnit feature: the @RepeatedTest(n) tag. If you use this tag to designate a test method instead of @Test the method will be executed n times. In this context executing a test multiple times is advantageous because, recall, that before each test we randomly generate new test parameters. To see how this works let's temporarily add a "test" method that does nothing but print out the randomly generated parameters:
private static int count = 0;
@RepeatedTest(10)
public void testRepeatedTest()
{
String fmt =
"%3d. Rectangle=(x=%4.1f,y=%4.1f,w=%6.1f,h=%6.1f) "
+ "gridUnit=%4.1f LPU=%2.0f%n";
System.out.printf(
fmt,
++count,
defRect.getX(),
defRect.getY(),
defRect.getWidth(),
defRect.getHeight(),
defGridUnit,
defLPU
);
}
The above method produces this output:
1. Rectangle=(x=80.0,y=77.6,w=2318.8,h=1973.2) gridUnit=49.8 LPU=46
2. Rectangle=(x=49.5,y=87.8,w=2185.0,h=1762.8) gridUnit=27.3 LPU=18
3. Rectangle=(x=62.7,y=62.2,w=2831.1,h= 456.7) gridUnit=10.7 LPU= 7
4. Rectangle=(x=77.8,y=77.4,w= 439.9,h=2034.1) gridUnit=42.3 LPU=18
5. Rectangle=(x=55.7,y= 1.1,w=2211.7,h=2955.2) gridUnit=14.8 LPU= 4
6. Rectangle=(x=55.9,y=98.3,w=2306.5,h=1298.8) gridUnit=27.3 LPU= 9
7. Rectangle=(x= 1.0,y=19.1,w=2148.1,h=2986.3) gridUnit=14.4 LPU= 2
8. Rectangle=(x=71.7,y=62.8,w=1449.8,h=2984.3) gridUnit=41.2 LPU=34
9. Rectangle=(x=24.0,y=37.8,w=1808.5,h=2353.7) gridUnit=21.5 LPU= 5
10. Rectangle=(x=98.6,y=47.4,w=1475.8,h=2535.8) gridUnit=25.4 LPU=22
So if I designate a test method using:
@RepeatedTest(1000)
It's the equivalent of executing 1000 different tests using uniquely generated test parameters each time. Here's the test method for getTotalHorizontalLines; the test method for getTotalVerticalLines is similar.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | @RepeatedTest(1000) public void testGetTotalHorizontalLines() { LineGenerator gen = new LineGenerator( defRect, defGridUnit, defLPU, -1, LineGenerator.HORIZONTAL ); float expNumLines = defMetrics.getNumHorizLines(); float actNumLines = gen.getTotalHorizontalLines(); expNumLines = 0; for ( Line2D line : gen ) expNumLines++; assertEquals( expNumLines, actNumLines ); } |
By the way, notice that the method validates that getTotalHorizontalLines returns the expected value, and that the iterator actually generates the expected number of lines.
Next let's verify that LineGenerator consistently generates lines of the expected length when the length is explicitly provided. To do this we will take advantage of yet another new JUnit tag: @ParameterizedTest. This tag is always used in conjunction with a source tag, in this case @ValueSource. The designated test method also requires a parameter. The pattern of the declaration looks like this:
@ParameterizedTest
@ValueSource( types = { array of literal values } )
public void testLineLength( type param )
{
// ...
}
In the above, type can be a stand-in for a primitive type or string. (The type is actually somewhat more flexible than this, and there altrnatives to @ValueSource; see the JUnit User Guide for complete details. See also DemoParameterizedTest in the sandbox package of the test source tree.) Here are a couple of examples:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | @ParameterizedTest @ValueSource ( ints = {100, 200, 300} ) public void generateIntParametersUsingValueSource( int testParam ) { System.out.println( "int parameter " + testParam ); assertTrue( testParam > 50 ); } @ParameterizedTest @ValueSource ( strings = {"manny", "moe", "jack"} ) public void generateStringParametersUsingValueSource( String testParam ) { System.out.println( "String parameter " + testParam ); assertTrue( testParam.length() < 50 ); } |
Now we can use @ParameterizedTest to generate multiple tests to verify that explicitly provided line lengths are correctly processed.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | @ParameterizedTest @ValueSource( floats = { 1, 1.5f, 2, 2.5f, 3, 3.5f, 4, 4.5f, 5 } ) public void testLineLength( float expLen ) { LineGenerator gen = new LineGenerator( defRect, defGridUnit, defLPU, expLen, LineGenerator.BOTH ); for ( Line2D actLine : gen ) { Line2D expLine = defMetrics.getLineSegment( actLine, expLen ); LineMetrics.assertLineEquals( expLine, actLine, epsilon ); } } |
I know we're not quite done yet, but let's have a look at our code coverage metrics so far.
assertThrows(Class<T> expectedType, Executable executable)
For the expected type we'll need the Class object from the exception we expect to be thrown, which is NoSuchElementException:
Class<NoSuchElementException> clazz =
NoSuchElementException.class;
For the executable we'll once again take it on faith (at least for now) that this bit of code will do the job:
() -> iter.next()
For each of the horizontal and vertical line iterators we will need a test to:
- Instantiate a LineGenerator;
- Get the iterator;
- Loop through the iterator until it's exhausted; and
- Employ the assertion: assertThrows( clazz, () -> iter.next() )
Here's the test for the horizontal line iterator. The test for the vertical line iterator is similar and can be found in the GitHub repository.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | @Test public void testGoWrongHorizontal() { Class<NoSuchElementException> clazz = NoSuchElementException.class; LineGenerator gen = new LineGenerator( defRect, defGridUnit, defLPU, -1, LineGenerator.HORIZONTAL ); Iterator<Line2D> iter = gen.iterator(); // first exhaust the iterator... while ( iter.hasNext() ) iter.next(); // now blow it away assertThrows( clazz, () -> iter.next() ); } |
And that gives us 100% code coverage on the LineGenerator class.
Summary
So, in summary, what are some of the major takeaways from this lesson?
- Unit testing is the responsibility of the developer, not the test group. Having said that, I would encourage the developer to work closely with the test group. If the developer knows what and how the test group is performing their tests, the developer might be able to make their job easier by some painless restructuring of code. And the test group might very well have tools that the developer would find helpful in unit testing and/or debugging.
- Unit testing is an integral part of the development process. In fact you may have heard about a strategy called test-driven development in which unit tests are written before any of the code they're intended to validate. If a project manager asks you for a time estimate for developing a particular bit of code your estimate should include the time it will take to develop the unit tests; which, as you can see from this lesson, could be a significant effort.
- Unit testing targets the public elements of your code. Students often ask me "But how am I supposed to test my private methods?" My answer to that is always:
- Are you really talking about unit testing, or are you talking about debugging? Because if your debugging you have a number of strategies that are not available for unit testing, including temporarily modifying the code to investigate particular aspects of the problem.
- You should be able to reach all of the code for all of your private methods by testing your public methods (yes, there are occasional, infrequent exceptions to this). If you can't reach code in your private methods via your public methods ask yourself: why is that code even there?
- JUnit is a terrific tool for developing unit tests. Use it. Study it. Read a textbook or take a tutorial that's more focused on JUnit than this one is.
- Unit test code is "real" code. If your organization has coding standards they should apply to your unit test code. Take as much care with your test code as you do with your production code; to paraphrase the punchline to the joke about getting to Carnegie Hall, encapsulate, encapsulate, encapsulate. Your unit tests should be under source control. Your unit test code has to be updated when the associated source code is changed.
Next: The Cartesian Plane Project, Property Management
No comments:
Post a Comment