I'm relatively new to unit testing and having gone through quite a bit of pain I was starting to feel pretty good about my tests. The problem is, now I have a nice set of green ticks I'm also suffering from the nagging doubt that it's just giving me a false sense of security and is in fact just a waste of time.
I have included an example of a method under test and all its accompanying unit tests. My approach was to try and use spies to ensure only the method itself was being tested (the intention being that other methods will be covered in their own tests). So for example my validation methods throw an exception when a value fails validation but these are caught and ignored here, and only the fact the validation is called with the correct parameters is tested.
Writing these tests felt pretty good, but now I can't really see the point of them. The only thing which can break these tests is a change to this specific method and surely if anybody is changing this method they are doing so with good reason, and all I've done is given them an extra job of changing the tests.
I can definitely say that writing these tests caused me to make several improvements to the way I wrote the method under test so there is benefit from that point of view but the unit tests which are left behind feel a bit redundant.
So am I wrong in my approach? Or in my analysis of the value my approach? There are so many evangelists for unit testing - many of whom are far cleverer than me - so I'm pretty sure it's me that's wrong somewhere.
Method under test:
/**
* Calculate the physical centre x co-ordinate for the passed unscaled value
* @param {string} unscaledValue - The logical cx value to be converted to a physical value. It could be a string for category axes or a number for measure axes.
* @param {number} [innerBarCount] - The number of small bars within a bar group. This is only required for multiple category axes.
* @param {number} [offset] - The zero based index of an inner bar within a bar group. This is only required for multiple category axes.
*/
this._getCx = function (unscaledValue, innerBarCount, offset) {
var returnCx = 0;
// Validate the required parameters and properties
dimple.validation._isDefined("x axis", this.x);
dimple.validation._isDefined("unscaledValue", unscaledValue);
// Act based on axis types
if (this.x._hasMeasure() || this.x._hasTimeField()) {
// Measures can return a straight scale
returnCx = this.x._scaleValue(unscaledValue);
}
else if (this.x._hasMultipleCategories()) {
// For multiple categories the second two parameters are required
dimple.validation._isPositiveNumber("innerBarCount", innerBarCount);
dimple.validation._isPositiveNumber("offset", offset);
// Scale to get the left position and then calculate the inner position of the bar based on offset
// plus a half accounting for bar gaps
returnCx = this.x._scaleValue(unscaledValue) + this._xBarGap() + (offset + 0.5) * (this._xBarSize() / innerBarCount);
} else if (this.x._hasCategories()) {
// Scale to get the left position of the bar and add half the bar size to get the centre
returnCx = this.x._scaleValue(unscaledValue) + (this.x._pointSize() / 2);
} else {
throw dimple.exception.unsupportedAxisState("x");
}
return returnCx;
};
And the tests themselves:
describe("dimple.series._getCx", function() {
var seriesUnderTest = null,
// Mock return values as ascending primes to avoid coincidental passes
unscaledValue = 2,
scaleReturn = 3,
innerBarCount = 7,
offset = 11,
barGap = 13,
pointSize = 17,
barSize = 19;
beforeEach(function () {
// The axis to return mock values while testing
var mockAxis = jasmine.createSpyObj("axis spy", [
"_hasMeasure",
"_hasCategories",
"_hasMultipleCategories",
"_hasTimeField",
"_scaleValue",
"_pointSize"
]);
// These will be individually overridden in tests to mock different axis types
mockAxis._hasMeasure.andReturn(false);
mockAxis._hasCategories.andReturn(false);
mockAxis._hasMultipleCategories.andReturn(false);
mockAxis._hasTimeField.andReturn(false);
// Set the return type dimensions
mockAxis._scaleValue.andReturn(scaleReturn);
mockAxis._pointSize.andReturn(pointSize);
// Instantiate the series to test
seriesUnderTest = new dimple.series();
seriesUnderTest.x = mockAxis;
// Set up series mocks
spyOn(seriesUnderTest, "_xBarGap").andReturn(barGap);
spyOn(seriesUnderTest, "_xBarSize").andReturn(barSize);
// Set up validation spies
spyOn(dimple.validation, "_isDefined").andReturn(true);
spyOn(dimple.validation, "_isNumber").andReturn(true);
spyOn(dimple.validation, "_isPositiveNumber").andReturn(true);
});
it("Validates required members", function () {
try { seriesUnderTest._getCx(unscaledValue); }
catch (ignore) { /* validation is not under test */ }
expect(dimple.validation._isDefined).toHaveBeenCalledWith("x axis", seriesUnderTest.x);
});
it("Validates required parameters", function () {
try { seriesUnderTest._getCx(unscaledValue); }
catch (ignore) { /* validation is not under test */ }
expect(dimple.validation._isDefined).toHaveBeenCalledWith("unscaledValue", unscaledValue);
});
it("Does not validate optional parameters for axes other than multiple category", function() {
try { seriesUnderTest._getCx(unscaledValue, innerBarCount, offset); }
catch (ignore) { /* validation is not under test */ }
expect(dimple.validation._isPositiveNumber).not.toHaveBeenCalled();
expect(dimple.validation._isPositiveNumber).not.toHaveBeenCalled();
});
it("Validates optional parameters for multiple category axes", function() {
seriesUnderTest.x._hasMultipleCategories.andReturn(true);
try { seriesUnderTest._getCx(unscaledValue, innerBarCount, offset); }
catch (ignore) { /* validation is not under test */ }
expect(dimple.validation._isPositiveNumber).toHaveBeenCalledWith("innerBarCount", innerBarCount);
expect(dimple.validation._isPositiveNumber).toHaveBeenCalledWith("offset", offset);
});
it("Throws an exception if axis returns false for all types", function() {
expect(function () { seriesUnderTest._getCx(unscaledValue); })
.toThrow(dimple.exception.unsupportedAxisState("x"));
expect(seriesUnderTest.x._hasMeasure).toHaveBeenCalled();
expect(seriesUnderTest.x._hasCategories).toHaveBeenCalled();
expect(seriesUnderTest.x._hasMultipleCategories).toHaveBeenCalled();
expect(seriesUnderTest.x._hasTimeField).toHaveBeenCalled();
});
it("Uses the x axis scaling for measure axes", function() {
seriesUnderTest.x._hasMeasure.andReturn(true);
expect(seriesUnderTest._getCx(unscaledValue)).toEqual(scaleReturn);
expect(seriesUnderTest.x._hasMeasure).toHaveBeenCalled();
expect(seriesUnderTest.x._scaleValue).toHaveBeenCalledWith(unscaledValue);
});
it("Uses the x axis scaling for time axes", function() {
seriesUnderTest.x._hasTimeField.andReturn(true);
expect(seriesUnderTest._getCx(unscaledValue)).toEqual(scaleReturn);
expect(seriesUnderTest.x._hasTimeField).toHaveBeenCalled();
expect(seriesUnderTest.x._scaleValue).toHaveBeenCalledWith(unscaledValue);
});
it("Calculates middle bar position for multiple categories", function() {
seriesUnderTest.x._hasMultipleCategories.andReturn(true);
expect(seriesUnderTest._getCx(unscaledValue, innerBarCount, offset))
.toEqual(scaleReturn + barGap + (offset + 0.5) * (barSize / innerBarCount));
expect(seriesUnderTest.x._hasMultipleCategories).toHaveBeenCalled();
expect(seriesUnderTest.x._scaleValue).toHaveBeenCalledWith(unscaledValue);
expect(seriesUnderTest._xBarGap).toHaveBeenCalled();
expect(seriesUnderTest._xBarSize).toHaveBeenCalled();
});
it("Calculates middle bar position for single categories", function() {
seriesUnderTest.x._hasCategories.andReturn(true);
expect(seriesUnderTest._getCx(unscaledValue))
.toEqual(scaleReturn + pointSize / 2);
expect(seriesUnderTest.x._hasCategories).toHaveBeenCalled();
expect(seriesUnderTest.x._scaleValue).toHaveBeenCalledWith(unscaledValue);
expect(seriesUnderTest.x._pointSize).toHaveBeenCalled();
});
});