Versions Compared

Key

  • This line was added.
  • This line was removed.
  • Formatting was changed.
Comment: Migrated to Confluence 5.3

In Part 6 of this series I began rewriting some of the embed code from the UpdateDetail procedure in the Invoice app as a reusable, testable class. Although I simply showed some test code on its own, that code lives inside a ClarionTest-compatible test DLL.

If you're following along, read Creating a ClarionTest test DLL for instructions on how to create a new test DLL. 

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. Make sure you're on the Defaults tab, not the Templates tab!

Image Removed

You'll notice that the procedure properties have been preloaded with a prototype and parameters. Do not change these values or the test procedure will not work and may cause a GPF in ClarionTest or your DLL.

Image Removed

Now, right-click on the procedure and choose Source to bring up the Embeditor

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:

Code Block
detail  InvoiceDetail

And here's the code for the source embed:

...

In Part 6 of this series I began rewriting some of the embed code from the UpdateDetail procedure in the Invoice app as a reusable, testable class. Although I simply showed some test code on its own, that code lives inside a ClarionTest-compatible test DLL.

If you're following along, read Creating a ClarionTest test DLL (DCL) for instructions on how to create a new test DLL. 

The first test procedure

Click on New Procedure and add a procedure called CreateDetail_SetPriceAndQuantity_VerifyExtended.

In the Select Procedure Type dialog click on the Defaults tab and choose Test Procedure. Make sure you're on the Defaults tab, not the Templates tab!

Image Added

You'll notice that the procedure properties have been preloaded with a prototype and parameters. Do not change these values or the test procedure will not work and may cause a GPF in ClarionTest or your DLL.

Image Added

Now, right-click on the procedure and choose Source to bring up the Embeditor

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:

Code Block
detail  InvoiceDetail

And here's the code for the source embed:

Code Block
    detail.Init(12.50,3)
    AssertThat(detail.GetExtended(),IsequalTo(37.50),'Wrong detail value')

Once you've entered the code your test procedure should look like this in the embeditor:

...

For purposes of this article, I'll follow the approach I outlined in Quick starts for classes

 Using the Quick Starts described in that article (or by creating a file starting from scratch, ) create a file called InvoiceDetail.inc with the following contents:

...

Include('InvoiceDetail.inc'),once 

Save the embed. 

You should now be able to compile the DLL.

...

There's one more thing you should do now to make unit testing as painless as possible, and that's to set up ClarionTest in the 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 ClarionTest.exe, and set the Arguments field to "${TargetPath}" as in Figure 8. You don't need the quotes if you will never have spaces in your path names.

Image Removed
Figure 8. Setting up ClarionTest as an external tool

...

Image Added

With the test DLL as the active window in the IDE, choose Tools | Run unit tests. You should see the ClarionTest window with the test DLL loaded, as in Figure 9.

Image Removed
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.

...

:

Image Added
Figure 10. Test failure

GetExtended just returns zero. Change the class code to store the price and quantity in private variables, as follows:

...

If you exited the ClarionTest 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 run the test again - it should now pass. You can also tick the Run on DLL change box to have any currently selected tests run automatically whenever the DLL is rebuilt. 

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

Code Block
    detail.Init(

...

11.

...

45,3)
    detail.SetDiscountRate(

...

11)
    

...

AssertThat(detail.GetDiscount()

...

,IsEqualTo(3.

...

78),'Wrong 

...

discount amount')

The test uses 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; the test expects a rounded value of 3.78.

Again, the test won't compile because the GetDiscount and SetDiscountRate methods don't yet exist. 

Add the declarations in the class header:

Code Block
GetDiscount     

...

        

...

procedure,real
SetDiscountRate         

...

procedure(real 

...

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:

...

discountRate)

And the implementation of the method (I've set GetDiscount to return 0 as a default):

Code Block
InvoiceDetail.GetDiscount           procedure
    code 
    return 

...

0
 
InvoiceDetail.SetDiscountRate       procedure(real discountRate)

...

And the implementation of the method (I've set GetDiscount to return 0 as a default):

...


    code 
    self.discountRate = discountRate

Run the test - it fails, because there's no code to calculate the actual discount.

Code Block
InvoiceDetail.GetDiscount   

...

PROCEDURE
    code 
    return 

...

self.discountRate / 100 

...

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.

...

* self.GetExtended()

Unfortunately, even after implementing the method the test fails!

 Image Added

The test returns the unrounded value of 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! Floating point math is subject to small errors. Use decimal types instead which will invoke Clarion's Binary Coded Decimal (BCD) library, the recommended option for all calculations where errors are unacceptable. That means scrubbing the calculation of any Real values. 

Here's an updated GetDiscount():

Code Block
InvoiceDetail.GetDiscount   

...

     

...

      

...

 

...

 

...

 

...

 

...

And what do you know - it works!

Image Removed
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.

Image Removed
Figure 12. The test suite

...

 PROCEDURE
Extended                                        decimal(10,2)
Result                                          decimal(10,2)
    code 
    Extended = self.GetExtended()
    Result = self.discountRate / 100 * Extended
    return Result

I used to think that as long as you had at least one decimal field in a calculation that the BCD library would be invoked. But the help says this about addition, multiplication and subtraction:

Performed as a BCD operation when neither operand has a REAL Base Type (both are LONG or DECIMAL) and one has the DECIMAL Base Type. 

and this about division:

Performed as a BCD operation when neither operand has a REAL Base Type (both are LONG or DECIMAL). 

It seems the safe thing to do is to ensure that no reals are involved, which is why I've saved the returned value to a local variable. It is however safe to return the value as a real since no math is done at that point. 

Another way to approach this is to have a calculation method that updates class properties each time some value changes - that would save storing a local copy prior to doing the calculation.

But why don't I just use decimals entirely, instead of reals anywhere? Mainly because decimals have to be passed by address and can't otherwise be a return value. I find that a bit clunky. 

More tests

Here are the rest of the tests, which while not yet exhaustive do a reasonable job of covering the bases. You can find them in InvoiceDetail_Tests.app in the source download.

Image Added

Here's the completed class header:

Code Block
InvoiceDetail       Class,Type,module('InvoiceDetail.clw'),link('InvoiceDetail.clw',1)
GetDiscount             procedure,real
GetExtended             procedure,real
GetSavings              procedure,real
GetTax                  procedure,real
GetTotal                procedure,real
Init                    procedure(real price,long quantity)
SetDiscountRate         procedure(real discountRate)
SetTaxRate              procedure(real taxRate)
price                   decimal(10,2),PRIVATE
discountRate            decimal(5,2),private
quantity                long,PRIVATE
taxRate                 decimal(5,2),private
                    End 

And the method source:

Code Block
    include('InvoiceDetail.inc'),once
InvoiceDetail.GetDiscount                   PROCEDURE
Extended                                        decimal(10,2)
Result                                          decimal(10,2)
    code 
    Extended = self.GetExtended()
    Result = self.discountRate / 100 * Extended
    return Result
    
    
InvoiceDetail.GetExtended                   PROCEDURE
    code
    return self.price  * self.quantity
    
InvoiceDetail.GetSavings                    PROCEDURE
    code 
    return self.GetDiscount()
InvoiceDetail.GetTax                        procedure     
result                                          decimal(10,2)
Extended                                        decimal(10,2)
Discount                                        decimal(10,2)
    code 
    Extended = self.GetExtended()
    Discount = self.GetDiscount()
    result = self.taxRate / 100 * (Extended - Discount)
    return result
   
InvoiceDetail.GetTotal                      PROCEDURE
    code 
    return self.GetExtended() - self.GetDiscount() + self.GetTax()    
    
InvoiceDetail.Init                          procedure(real price,long quantity)    
    code 
    self.price = price
    self.quantity = quantity
    
InvoiceDetail.SetDiscountRate               procedure(real discountRate)
    code 
    self.discountRate = discountRate
InvoiceDetail.SetTaxRate                    procedure(real taxRate)
    code 
    self.taxRate = taxRate

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. statement:

Include('InvoiceDetail.inc') 

And in the UpdateDetail procedure, add this line in the data section:

...

    detail.Init(DTL:price,DTL:QuantityOrdered)
    detail.SetTaxRate(DTL:TaxRate)
    detail.SetDiscountRate(DTL:DiscountRate)
    DTL:TaxPaid = detail.GetTax()
    DTL:Discount = detail.SetDiscountRateGetDiscount(DTL:DiscountRate)
    DTL:TaxPaidTotalCost = detail.GetTaxGetTotal()
    DTL:DiscountSavings = 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.

...

GetSavings()

You could create an overloaded Init() method to take the tax and discount rates as well, and reduce this code from seven lines to five. I tend not to go for methods with a large number of parameters unless all are necessary.

To update the totals on each change (including spin box clicks) I moved the do CalcValues call to the window's TakeEvent call. 

File locations

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.

In the downloadable source you'll see that I've added a Clarion80.red file to the InvoiceDetail_Tests and Invoice directories. Since both these apps share the InvoiceDetail.* files I chose to put them in a common location: the libsrc directory. 

The Clarion80.red files are quite short. They include the standard Clarion80.red file and add a directive for the ..\libsrc directory:

Code Block
{include %Root%\BIN\Clarion80.RED}
[Common]
*.*   = ..\libsrc;

Summary

The original routine contained a hodgepodge of untestable, difficult to reuse code tied directly to a particular database.

...

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.

Source code 

Download the source here