Clarifying some testing terminology
We often talk about testing in terms of various test types - unit testing, functional or system testing, integration testing, etc. But what do we actually mean when we refer to any of these terms? After all, finding common words to name things is the key to mutual understanding. In my previous post, I also mentioned some concepts, such as unit testing, white box testing, and black box testing. And I think this is good idea to define this terminology before I carry on my testing venture.
Let's start with the definition of unit testing, probably the most misused title around testing practices. It often happens that people stick the tag of "unit testing" to any kind of test done by developers - whatever developers do is a unit test, and after a successful round of unit tests the code can be passed to testers/functional consultants for system or functional testing. And this seems to be a very common misunderstanding of the terminology.
Unit testing is the lowest level of the testing activities, aimed on testing of separate units of code - methods and classes - in isolation. Many testing purists stipulate that a unit test cannot have any external dependencies whatsoever, meaning that a unit test cannot communicate with the network, database, printer, or any other external resource. And this puristic approach makes sense, considering two key objectives of unit tests:
Unit tests are small and fast - they are easy to read and quick to run.
Unit tests are aimed on a specific method or object, eliminating any dependencies from other components of the system.
A large number of units tests form the base layer of the test harness, covering low-level technical details
Business Central functionality is too tightly coupled with the database, making it nearly impossible to test any bit of functionality without communicating with the database. Therefore, in BC testing we have to reconsider the scope of a unit test and allow language statements running DB queries. So, for the purposes of Business Central testing, we can define a unit test as a test verifying functionality of a separate method or object, which cannot have dependencies on user input or any external systems (including network or file system), but can run AL statements triggering database queries. This is the definition I will adhere to when writing about unit tests.
Usually it is not possible to execute a unit test manually. For this reason, unit tests are always automated. If a project does not include autotests, the unit testing layer does not exist. As simple as that: no automated testing - no unit tests. Most of the testing done by developers in this case will be actually carried out on the integration level.
Of course, the borderline between a unit test and a integration test is not defined once and for all, in black and white. Examples below will illustrate the difference between the two types of tests. All of the examples are testing some pieces of code in the Location table. The first set of tests is covering the function IsInTransit which returns a Boolean value indicating if the location is a transit one. Here is the function itself.
procedure IsInTransit(LocationCode: Code): Boolean begin if Location.Get(LocationCode) then exit(Location."Use As In-Transit"); exit(false); end;
Function is very simple, public, and needs only three unit tests to cover all possible outputs.
The first test is verifying that the function return value is True when called on an in-transit location.
[Test] procedure IsIntransitLocationPositive() var Location: Record Location; begin LibraryWarehouse.CreateInTransitLocation(Location); Assert.IsTrue( Location.IsInTransit(Location.Code), UnexpectedResultErr); end;
Test number two is expecting False when calling the function on a non-transit location.
[Test] procedure IsIntransitLocationNegative() var Location: Record Location; begin LibraryWarehouse.CreateLocation(Location); Assert.IsFalse( Location.IsInTransit(Location.Code), UnexpectedResultErr); end;
And finally, test three verifies the return value for a random string that does not correspond to a location code.
[Test] procedure IsIntransitLocationNonExistingLocationCode() var Location: Record Location; begin Assert.IsFalse( Location.IsInTransit( LibraryUtility.GenerateGUID()), UnexpectedResultErr); end;
This is all it takes to test the function IsInTransit. There is no doubt that all three test cases fall into the unit testing category (with the assumption that database queries are acceptable in BC unit tests, made above). Each test is targeted specifically on a single method and does not invoke other system components.
In the following examples, we will test the trigger OnDelete of the same table "Location". Test classification becomes more complicated here and falls into that gray area between unit and integration testing. OnDelete trigger runs multiple checks and verifications on related data entities, and depending on the test goals and implementation, we can either cover this functionality with unit tests, or take a step on the integration testing level.
One of the tasks that the OnDelete trigger performs is to clean up related data, leaving no orphan records behind. The next test example verifies that the trigger deletes a transfer route where the location being deleted is used as the transfer source.
[Test] procedure TransferRouteDeletedOnDeletingFromLocation() var Location: array of Record Location; TransferRoute: Record "Transfer Route"; begin LibraryWarehouse.CreateLocation(Location); LibraryWarehouse.CreateLocation(Location); LibraryWarehouse.CreateTransferRoute( TransferRoute, Location.Code, Location.Code); Location.Delete(true); Assert.IsFalse(TransferRoute.Find(), UnexpectedResultErr); end;
This is still is clear unit test with no dependencies on any objects besides the Location table itself. Procedures CreateLocation and CreateTransferRoute from the Library - Warehouse insert records in respective tables and don't introduce any dependencies.
Now let's have a look at another condition verified in the OnDelete trigger, which is verifying that no open item ledger entries exist on the location being deleted.
ItemLedgerEntry.SetRange("Location Code", Code); ItemLedgerEntry.SetRange(Open, true); if not ItemLedgerEntry.IsEmpty() then Error(Text013, Code);
In order to test this condition, we need to create a location, post an item journal line on this location, and try to delete it. Translated to code, the test would look like this.
[Test] procedure DeleteLocationFailWhenOpenILEExistsPost() var Location: Record Location; ItemJournalLine: Record "Item Journal Line"; begin LibraryWarehouse.CreateLocationWithInventoryPostingSetup(Location); LibraryInventory.CreateItemJnlLine( ItemJournalLine, ItemJournalLine."Entry Type"::"Positive Adjmt.", WorkDate(), LibraryInventory.CreateItemNo(), LibraryRandom.RandInt(100), Location.Code); LibraryInventory.PostItemJournalLine( ItemJournalLine."Journal Template Name", ItemJournalLine."Journal Batch Name"); asserterror Location.Delete(true); Assert.ExpectedError( StrSubstNo(CannotDeleteLocationErr, Location.Code)); end;
And with test, we take a step on the new ground, away from the realm of unit testing. The code snippet above invokes the item journal posting routine, introducing a dependency on another system component besides the Location table which is being tested, thus turning this test case into an integration test. If we were to run this test manually, there would be no way to escape the dependency, but automated testing gives an option to mock the required input data, as the following example demonstrates.
[Test] procedure DeleteLocationFailWhenOpenILEExistsMock() var Location: Record Location; ItemLedgerEntry: Record "Item Ledger Entry"; begin LibraryWarehouse.CreateLocation(Location); ItemLedgerEntry."Entry No." := GetNextItemLedgerEntryNo(); ItemLedgerEntry."Location Code" := Location.Code; ItemLedgerEntry.Open := true; ItemLedgerEntry.Insert(); asserterror Location.Delete(true); Assert.ExpectedError( StrSubstNo(CannotDeleteLocationErr, Location.Code)); end;
Here, instead of running the complete journal posting procedure, we mock an entry with two key values - location code and "Open" flag. And this version of the test eliminates the dependency returning the test onto the unit testing ground.
The downside of integration testing is its lack of precision in identifying the exact issue that caused the test failure. If there is an error in the posting routine, the test that invokes it will fail, although it is aimed to verify another piece of functionality. The latter approach allows to test the deletion trigger in isolation. An error in the journal posting procedure will not affect the location deletion test, allowing more precise diagnostics.
Another important benefit of unit testing is illustrated in the following screenshot.
Two highlighted tests do the same verification, but the unit test that mocks the inputs, runs 20 times faster compared to the test running the full posting routine.
But nothing is free, and the benefits of unit testing come at a cost of the focus on implementation details rather then the application functional logic. Unit test mocks the item ledger entry, making assumptions about the low-level technical aspects of the verification, and this shift of focus can be the source of an overhead effort for refactoring. But this is a topic for another post. Let me stop here for now, concluding the overview of unit testing in Business Central.
The last code sample is one helper procedure and global variables used in the previous tests.
local procedure GetNextItemLedgerEntryNo(): Integer var ItemLedgerEntry: Record "Item Ledger Entry"; begin if ItemLedgerEntry.FindLast() then exit(ItemLedgerEntry."Entry No." + 1); exit(1); end; var LibraryUtility: Codeunit "Library - Utility"; LibraryWarehouse: Codeunit "Library - Warehouse"; LibraryInventory: Codeunit "Library - Inventory"; LibraryRandom: Codeunit "Library - Random"; Assert: Codeunit Assert; UnexpectedResultErr: Label 'Unexpected function return value'; CannotDeleteLocationErr: Label 'You cannot delete %1 because there are one or more ledger entries on this location';