In Part 4 of this series I began rewriting some of the embed code from the UpdateDetail procedure in the Invoice app as a reusable, testable class. I also showed how to create a DLL that's compatible with the CTest test runner utility. Now it's time to create a test procedure.
The first test procedure
Click on New Procedure and add a procedure called Test_CreateDetail_SetPriceAndQuantity_VerifyExtended
.
In the Select Procedure Type dialog click on the Defaults tab and choose Test Procedure (Figure 5).
Figure 5. Choosing the test procedure template
You'll notice that the procedure properties have been preloaded with a prototype and parameters (Figure 6). Do not change these values or the test procedure will not work, and may cause a GPF in CTest or your DLL.
Figure 6. Properties for the test procedure
Now, right-click on the procedure and choose Source to bring up the Embeditor (Figure 7).
Figure 7. The generated code
There really are only two embeds you need to be concerned about in the test procedure. One is the data embed, where you declare any data you need for the test. The other is Priority 5000, right before the return statement. That's where you place your test code.
Here's the code for the data embed:
detail InvoiceDetail
And here's the code for the source embed:
detail.Init(12.50,3) if detail.GetExtended() <> 37.50 tr.message = 'Expected 37.50, got ' & detail.GetExtended() else tr.passed = true end
Save and try to compile the DLL. If you forgot to add the global extension to the DLL you'll get about 25 errors; if you remembered, you'll get something like seven errors.
Clearly there's a problem because you haven't yet defined the class!
The simplest way to create classes
There are a number of different ways you can declare classes in Clarion; some of them are fraught with danger. For instance, if you create classes that you export from a DLL so they can be used elsewhere, you had better get everything just right or you'll probably be looking at GPFs.
The approach I'll show you is the simplest, most foolproof way I know to create classes in Clarion and still reuse those classes everywhere you like.
First, add a new Clarion source file to your test DLL project (you can move it later). Now, adding a new source file to C7 via the solution explorer is pretty buggy.
Right-click on the project entry in the solution explorer (not the .APP itself). Choose Add | New Item, and pick any of the Clarion file types, it doesn't matter which.
C7 will add the file to the project tree, and it will open the file in the editor. But the file doesn't yet exist on disk. So you have to save it first, making sure you give it exactly the same name again.
One more thing - highlight the file in the solution explorer, and on the Properties pad change the Build Action from Compile to None. You don't want the project system compiling this file; it's going to be an INCLUDEd file.
With this new source file open (I called mine InvoiceDetail.clw) delete everything in the file and replace it with this code:
Section('Header') InvoiceDetail Class,Type GetExtended procedure,real Init procedure(real price,long quantity) End Section('Methods') InvoiceDetail.GetExtended PROCEDURE Code Return 0 InvoiceDetail.Init procedure(real price,long quantity) Code
Now go back to the DLL APP file again. On the global embeds choose the After Global INCLUDEs embed point and insert the following source:
Include('InvoiceDetail.clw','Header')
Save the embed. Go to the Program Routines global embed and add the following source:
Include('InvoiceDetail.clw','Methods')
Notice that the Include statements both use a second parameter which corresponds to the Section heading in the source file. What you've done is include just the class header in the first embed, and the class methods in the second embed. You have to do this because it's a Clarion language requirement that class headers appear in the data section and class methods in the code section, and Program Routines is just a handy location in the program's code section.
You should now be able to compile the DLL.
Setting up CTest
There's one more thing you should do now to make unit testing as painless as possible, and that's to set up CTest in the C7 Tools menu. Choose Tools | Options, and from the list of Options choose Tools again. Click on Add to create an new external tool entry. Set the Command field to point to CTest.exe, and set the Arguments field to ${TargetPath} as in Figure 8.
Figure 8. Setting up CTest as an external tool
With the test DLL as the active window in the IDE, choose Tools | Run unit tests. You should see the CTest window with the test DLL loaded, as in Figure 9.
Figure 9. Loading the test DLL
You'll notice that the procedure name is in all caps - that's how the symbol is exported from the DLL. The procedure name is reformatted after the test is run, based on information returned by the test.
Press the Run All button. You should see a test failure, as in Figure 10.
Figure 10. Test failure
GetExtended just returns zero. Change the class code to store the price and quantity in private variables, as follows:
Section('Header') InvoiceDetail Class,Type GetExtended procedure,real Init procedure(real price,long quantity) price real,PRIVATE quantity long,PRIVATE End Section('Methods') InvoiceDetail.GetExtended PROCEDURE code return self.price * self.quantity InvoiceDetail.Init procedure(real price,long quantity) CODE self.price = price self.quantity = quantity
Yes, I realize I'm using a real for the price - bear with me for a little while.
Compile the DLL.
If you exited the CTest application, restart it from the Tools menu. If you still have it up just press Reload. Then press Run All. The test should now pass.
Now, how about getting the discount amount? That involves two things: setting a discount percentage rate and calculating the discount. Really there are two things to test: Is the discount amount right, and is the total right? Let's start with the discount amount.
Create a new procedure called Test_CreateDetail_SetDiscount_VerifyDiscount
. Select Test Procedure from the Template Types dialog, Defaults tab.
Or just highlight the first test, press Ctrl-C, then type in the new name. That will save a bit of setup typing.
Change the test code in this new procedure to
detail.Init(12.50,3) detail.SetDiscountRate(10) if detail.GetDiscount() <> 3.75 tr.message = 'Expected discount of 3.75, got ' & detail.GetDiscount() else tr.passed = TRUE end
Of course the test won't compile.
You'll get a number of unknown procedure errors field not found errors. That's because you don't have the SetDiscountRate
, GetDiscount
or GetTotal
methods in the class. Add the declarations in the class header:
GetDiscount procedure,real
SetDiscountRate procedure(real discountRate)
And the implementation of the method (I've set GetDiscount
to return 0 as a default):
InvoiceDetail.GetDiscount procedure code return 0 InvoiceDetail.SetDiscountRate procedure(real discountRate) code self.discountRate = discountRate
Here's a little tip to making finding the compile error easier. If you've mistyped something or otherwise caused an error in the include file, do not (at least in 7.0) double-click on the error in the error list - the IDE will take you to program file that contains the Include statement. Instead just look in the include file. The error(s) will be highlighted.
Run the test - it fails, because there's no code to calculate the actual discount.
InvoiceDetail.GetDiscount PROCEDURE code return self.discountRate / 100 * self.GetExtended()
And what do you know - it works!
Figure 11. Two tests pass!
But wait - that result conveniently rounds out to a single decimal place. What happens if you use, say, a value of $11.45 and a discount of 11%? Plug that into your calculator and 3 * 11.45 * .11 = 3.7785. That's not an acceptable currency value. It should round up to 3.78. And in fact the test returns 3.7785, which is a failure.
You could Round()
the result, but a better solution is simply not to use floating point numbers for financial calculations! Use decimal types instead. Here's an updated GetDiscount()
:
InvoiceDetail.GetDiscount PROCEDURE result decimal(10,2) code result = self.discountRate / 100 * self.GetExtended() return result
I've also changed the DiscountRate
variable to a Decimal(10,2)
. The Clarion Help file explains the rules under which the BCD (binary coded decimal) library is invoked - see Decimal Arithmetic in the index. The numeric constant (100) is a decimal, as is DiscountRate
, and as long as one side of a mulitiplication is a decimal that will be a BCD operation as well (GetExtended
returns a real). I store the resulting value in a temp decimal var for rounding purposes, and then return that value (again as a real).
But why don't I just use decimals entirely, instead of reals? Mainly because decimals have to be passed by address and can't otherwise be a return value. I find that a bit clunky. I'm not aware of any problems with passing the data as reals in this scenario, and if there are they should be uncovered by the unit tests ....
More tests
Figure 12 shows the remainder of the tests, which while not yet exhaustive do a reasonable job of covering the bases.
Figure 12. The test suite
You can find the code for the class in the downloadable source.
Implementing the class
Adding the class to the Invoice app is the same as adding it to the test DLL: just add the same two global include statements. And in the UpdateDetail
procedure, add this line in the data section:
detail InvoiceDetail
Then, in the CalcValues
routine, replace the existing embed code with this code:
detail.Init(DTL:price,DTL:QuantityOrdered) detail.SetTaxRate(DTL:TaxRate) detail.SetDiscountRate(DTL:DiscountRate) DTL:TaxPaid = detail.GetTax() DTL:Discount = detail.GetDiscount() DTL:TotalCost = detail.GetTotal() DTL:Savings = detail.GetSavings()
You could, in fact, create an overloaded Init()
method to take the tax and discount rates as well, and reduce this code from seven lines to five.
You'll also have to put the InvoiceDetail.clw file somewhere it can be found. That means either putting it in a directory that's already listed in the redirection file (such as the Clarion libsrc directory) or updating the redirection file. You may want to set up a source directory for just your own classes.
Summary
The original routine contained a hodgepodge of untestable, difficult to reuse code tied directly to a particular database.
The class that replaces that code isn't tied to any one database, can be reused easily without duplicating any business logic, and has an accompanying test suite to verify its behavior.
Extracting embed code into testable, reusable classes does involve some work, but the payoff is huge. You get verifiable code, and not just in the development phase. You can make changes to the class with much less risk of breaking existing code. If your unit tests are comprehensive, they'll catch any errors introduced by enhancements or even other bug fixes.
Tip: If you're using NetTalk, I'm told you need to add the "Suppress NetTalk" global extension in the test DLL.