From 6f72c21a6076b0bb2796847f350f30bac5edaf31 Mon Sep 17 00:00:00 2001 From: Jeremy Vyska Date: Tue, 20 Jan 2026 14:23:48 +0100 Subject: [PATCH] Orphaned files meant for the original PR --- .../agentic-coding/GettingMore/refactoring.md | 1041 +++++++++++++++++ .../agentic-coding/GettingMore/testing.md | 902 ++++++++++++++ 2 files changed, 1943 insertions(+) create mode 100644 content/docs/agentic-coding/GettingMore/refactoring.md create mode 100644 content/docs/agentic-coding/GettingMore/testing.md diff --git a/content/docs/agentic-coding/GettingMore/refactoring.md b/content/docs/agentic-coding/GettingMore/refactoring.md new file mode 100644 index 00000000..6aaac1c6 --- /dev/null +++ b/content/docs/agentic-coding/GettingMore/refactoring.md @@ -0,0 +1,1041 @@ +--- +title: "Refactoring Legacy Code" +linkTitle: "Refactoring" +weight: 4 +description: > + Use AI to modernize and improve existing AL code while maintaining functionality +--- + +## Scenario + +You've inherited a legacy AL codeunit from an older Business Central version. The code works, but it: + +- Uses deprecated patterns and APIs +- Has poor structure and naming +- Lacks documentation and error handling +- Contains duplicated logic +- Is difficult to test and maintain + +You need to modernize this code while ensuring it continues to work correctly. + +## Goal + +Use AI to help you: + +- Identify refactoring opportunities +- Modernize deprecated APIs +- Improve code structure +- Enhance readability and maintainability +- Add tests to ensure no regression + +## The Legacy Code + +Here's a typical legacy codeunit that needs refactoring: + +```al +codeunit 50100 "Sales Order Management" +{ + procedure ProcessSalesOrder(DocNo: Code[20]) + var + SH: Record "Sales Header"; + SL: Record "Sales Line"; + C: Record Customer; + I: Record Item; + qty: Decimal; + amt: Decimal; + begin + if DocNo = '' then + exit; + + SH.Get(SH."Document Type"::Order, DocNo); + C.Get(SH."Sell-to Customer No."); + + if C.Blocked <> C.Blocked::" " then begin + Message('Customer is blocked!'); + exit; + end; + + SL.SetRange("Document No.", DocNo); + if SL.Find('-') then + repeat + if SL.Type = SL.Type::Item then begin + I.Get(SL."No."); + if I.Inventory < SL.Quantity then + Message('Not enough inventory for item ' + I."No."); + end; + qty := qty + SL.Quantity; + amt := amt + SL."Line Amount"; + until SL.Next() = 0; + + if amt > 10000 then begin + SL.SetRange("Document No.", DocNo); + if SL.Find('-') then + repeat + SL."Line Discount %" := 5; + SL.Modify(); + until SL.Next() = 0; + end; + + Message('Processed order ' + DocNo + ' with total amount ' + Format(amt)); + end; +} +``` + +## Step-by-Step Refactoring Process + +### Step 1: Write Tests First + +{{% alert title="Critical: Test First!" color="warning" %}} +Before refactoring any code, write tests that verify its current behavior. These tests act as a safety net, ensuring you don't accidentally break functionality during refactoring. +{{% /alert %}} + +**Prompt**: + +``` +I need to refactor this legacy code, but first I need comprehensive tests for its current behavior. + +Create tests that verify: +- All current functionality (even if imperfect) +- Expected outputs for given inputs +- Error conditions and edge cases +- Current behavior (not ideal behavior) + +These tests should pass with the current code and catch any regressions during refactoring. + +[Paste the legacy code] +``` + +**Expected Test Code**: + +```al +codeunit 50101 "Sales Order Mgmt. Tests" +{ + Subtype = Test; + + var + Assert: Codeunit "Library Assert"; + LibrarySales: Codeunit "Library - Sales"; + + [Test] + procedure TestCurrentBehaviorWithValidOrder() + var + SalesHeader: Record "Sales Header"; + SalesOrderMgmt: Codeunit "Sales Order Management"; + begin + // [GIVEN] A valid sales order (testing CURRENT behavior) + CreateTestSalesOrder(SalesHeader, 'CUST001', 5000); + + // [WHEN] Processing the order + SalesOrderMgmt.ProcessSalesOrder(SalesHeader."No."); + + // [THEN] No error occurs and discount is applied + // (These tests lock in current behavior before refactoring) + Assert.IsTrue(true, 'Process completed without error'); + end; + + [Test] + procedure TestCurrentBlockedCustomerBehavior() + var + SalesHeader: Record "Sales Header"; + Customer: Record Customer; + SalesOrderMgmt: Codeunit "Sales Order Management"; + begin + // [GIVEN] Order with blocked customer + CreateBlockedCustomerOrder(SalesHeader, Customer); + + // [WHEN] Processing (currently just shows Message, doesn't error) + SalesOrderMgmt.ProcessSalesOrder(SalesHeader."No."); + + // [THEN] Process completes (testing CURRENT behavior, even if not ideal) + // Note: After refactoring, this might throw an error instead + end; + + // Additional tests for current behavior... +} +``` + +**Why This Matters**: + +- Tests catch regressions immediately +- You can refactor confidently +- Tests document current behavior +- You can run tests after each small refactoring step + +### Step 2: Initial Assessment + +**Prompt**: + +``` +Analyze this legacy AL code and identify refactoring opportunities. + +Categorize issues by: +- Critical: Deprecated APIs, security issues, bugs +- Major: Poor structure, missing error handling, performance issues +- Minor: Naming, formatting, documentation + +[Paste the legacy code] + +For each issue, explain: +- What the problem is +- Why it's problematic +- How to fix it +``` + +**Expected AI Findings**: + +**Critical Issues:** + +- Using `Message()` for errors instead of `Error()` +- No proper error handling (could cause partial updates) +- Using deprecated `Find('-')` syntax +- Modifying records in a loop without proper handling + +**Major Issues:** + +- Poor variable naming (SH, SL, C, I, qty, amt) +- Single large procedure doing multiple things +- Hard-coded business rules (10000 threshold, 5% discount) +- Inefficient database operations (multiple FindSet loops) +- No validation of input parameters +- Missing XML documentation +- No telemetry or logging + +**Minor Issues:** + +- No separation of concerns +- String concatenation for messages +- Magic numbers without explanation +- Inconsistent code style + +### Step 3: Create a Refactoring Plan + +**Prompt**: + +``` +Create a detailed refactoring plan for this code. + +I already have tests in place that verify current behavior. + +Break refactoring into phases: +1. Safe refactorings (no behavior change) +2. Structural improvements +3. API modernization +4. Behavioral improvements (may change behavior) +5. Final enhancements + +For each phase, list specific changes and estimated risk level. +After each phase, I'll run tests to ensure nothing broke. +``` + +**Expected Plan**: + +**Phase 0: Tests (COMPLETED)** + +- ✅ Written tests for current behavior +- ✅ Tests pass with current code +- ✅ Ready to refactor safely + +**Phase 1: Safe Refactorings (Low Risk)** + +- Rename variables to be descriptive +- Add XML documentation +- Extract magic numbers to constants +- Format code consistently +- Add code comments for complex logic + +**Phase 2: Structural Improvements (Medium Risk)** + +- Extract methods for each responsibility +- Separate validation, calculation, and update logic +- Remove code duplication +- Improve error handling + +**Phase 3: API Modernization (Medium Risk)** + +- Replace deprecated Find('-') with FindSet() +- Use proper Error() instead of Message() +- Add SetLoadFields for performance +- Use ModifyAll where appropriate + +**Phase 4: Behavioral Improvements (Medium Risk)** + +- Change Message() to Error() for validation failures +- This CHANGES behavior, so update tests accordingly +- Add new tests for improved error handling + +**Phase 5: Additional Testing (Low Risk)** + +- Add more edge case tests +- Add tests for each extracted method +- Expand test coverage + +**Phase 6: Enhancements (Low Risk)** + +- Add telemetry +- Implement proper transaction handling +- Add configuration instead of hard-coded values + +### Step 4: Run Your Tests + +**Before refactoring anything**, verify your tests pass: + +```powershell +# Run the test codeunit +Invoke-NAVCodeunit -CodeunitId 50101 +``` + +**All tests should pass.** If they don't, fix the tests first. + +### Step 5: Phase 1 - Safe Refactorings + +**Prompt**: + +``` +Perform Phase 1 refactorings on this code: +- Rename all variables to be descriptive +- Add XML documentation to the procedure +- Extract magic numbers (10000, 5) to constants +- Add inline comments for complex logic +- Improve code formatting + +[Paste the legacy code] +``` + +**Expected Result**: + +```al +/// +/// Manages sales order processing including validation, inventory checking, and discount application +/// +codeunit 50100 "Sales Order Management" +{ + var + LargeOrderThreshold: Decimal; + LargeOrderDiscountPct: Decimal; + + /// + /// Processes a sales order by validating customer, checking inventory, and applying discounts + /// + /// The sales order number to process + procedure ProcessSalesOrder(DocumentNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + Customer: Record Customer; + Item: Record Item; + TotalQuantity: Decimal; + TotalAmount: Decimal; + begin + // Initialize thresholds + LargeOrderThreshold := 10000; + LargeOrderDiscountPct := 5; + + // Validate input + if DocumentNo = '' then + exit; + + // Get sales order and customer + SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo); + Customer.Get(SalesHeader."Sell-to Customer No."); + + // Check if customer is blocked + if Customer.Blocked <> Customer.Blocked::" " then begin + Message('Customer is blocked!'); + exit; + end; + + // Check inventory and calculate totals + SalesLine.SetRange("Document No.", DocumentNo); + if SalesLine.Find('-') then + repeat + // Verify inventory availability for items + if SalesLine.Type = SalesLine.Type::Item then begin + Item.Get(SalesLine."No."); + if Item.Inventory < SalesLine.Quantity then + Message('Not enough inventory for item ' + Item."No."); + end; + + // Accumulate totals + TotalQuantity := TotalQuantity + SalesLine.Quantity; + TotalAmount := TotalAmount + SalesLine."Line Amount"; + until SalesLine.Next() = 0; + + // Apply discount for large orders + if TotalAmount > LargeOrderThreshold then begin + SalesLine.SetRange("Document No.", DocumentNo); + if SalesLine.Find('-') then + repeat + SalesLine."Line Discount %" := LargeOrderDiscountPct; + SalesLine.Modify(); + until SalesLine.Next() = 0; + end; + + // Notify user of completion + Message('Processed order ' + DocumentNo + ' with total amount ' + Format(TotalAmount)); + end; +} +``` + +### Step 6: Run Tests After Phase 1 + +**Critical**: After completing Phase 1 refactorings, run your tests: + +```powershell +Invoke-NAVCodeunit -CodeunitId 50101 +``` + +**All tests should still pass.** If any fail, the refactoring changed behavior - fix it before proceeding. + +### Step 7: Phase 2 - Structural Improvements + +**Prompt**: + +``` +Refactor this code to improve structure: + +1. Extract separate methods for: + - Customer validation + - Inventory checking + - Total calculation + - Discount application + +2. Replace Message() with proper Error() for validation failures + +3. Add proper error handling with try-catch where appropriate + +4. Use StrSubstNo for string formatting + +[Include the Phase 1 code] +``` + +**Expected Result**: + +```al +/// +/// Manages sales order processing including validation, inventory checking, and discount application +/// +codeunit 50100 "Sales Order Management" +{ + var + LargeOrderThreshold: Decimal; + LargeOrderDiscountPct: Decimal; + InsufficientInventoryErr: Label 'Insufficient inventory for item %1. Available: %2, Required: %3'; + CustomerBlockedErr: Label 'Customer %1 is blocked and cannot place orders.'; + OrderProcessedMsg: Label 'Successfully processed order %1 with total amount %2.'; + + /// + /// Processes a sales order by validating customer, checking inventory, and applying discounts + /// + /// The sales order number to process + procedure ProcessSalesOrder(DocumentNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + TotalAmount: Decimal; + begin + LargeOrderThreshold := 10000; + LargeOrderDiscountPct := 5; + + if DocumentNo = '' then + Error('Document number cannot be empty.'); + + if not SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then + Error('Sales order %1 not found.', DocumentNo); + + ValidateCustomer(SalesHeader); + CheckInventoryAvailability(SalesHeader); + TotalAmount := CalculateOrderTotal(SalesHeader); + + if TotalAmount > LargeOrderThreshold then + ApplyLargeOrderDiscount(SalesHeader); + + Message(OrderProcessedMsg, DocumentNo, Format(TotalAmount, 0, '')); + end; + + /// + /// Validates that the customer is not blocked + /// + /// The sales header to validate + local procedure ValidateCustomer(SalesHeader: Record "Sales Header") + var + Customer: Record Customer; + begin + if not Customer.Get(SalesHeader."Sell-to Customer No.") then + Error('Customer %1 not found.', SalesHeader."Sell-to Customer No."); + + if Customer.Blocked <> Customer.Blocked::" " then + Error(CustomerBlockedErr, Customer."No."); + end; + + /// + /// Checks that sufficient inventory is available for all items on the order + /// + /// The sales header to check + local procedure CheckInventoryAvailability(SalesHeader: Record "Sales Header") + var + SalesLine: Record "Sales Line"; + Item: Record Item; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.SetRange(Type, SalesLine.Type::Item); + + if SalesLine.FindSet() then + repeat + if Item.Get(SalesLine."No.") then + if Item.Inventory < SalesLine.Quantity then + Error(InsufficientInventoryErr, Item."No.", Item.Inventory, SalesLine.Quantity); + until SalesLine.Next() = 0; + end; + + /// + /// Calculates the total amount for the sales order + /// + /// The sales header to calculate totals for + /// The total amount of the order + local procedure CalculateOrderTotal(SalesHeader: Record "Sales Header"): Decimal + var + SalesLine: Record "Sales Line"; + TotalAmount: Decimal; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.SetLoadFields("Line Amount"); + + if SalesLine.FindSet() then + repeat + TotalAmount += SalesLine."Line Amount"; + until SalesLine.Next() = 0; + + exit(TotalAmount); + end; + + /// + /// Applies a discount to all lines on large orders + /// + /// The sales header to apply discounts to + local procedure ApplyLargeOrderDiscount(SalesHeader: Record "Sales Header") + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.ModifyAll("Line Discount %", LargeOrderDiscountPct, true); + end; +} +``` + +### Step 8: Run Tests After Phase 2 + +Run your tests again after structural improvements: + +```powershell +Invoke-NAVCodeunit -CodeunitId 50101 +``` + +Tests should pass. If not, review the structural changes. + +### Step 9: Phase 3 - API Modernization + +**Prompt**: + +``` +Modernize this code to use current AL best practices: + +1. Replace deprecated Find('-') with FindSet() +2. Add proper SetLoadFields for performance +3. Use better error handling patterns +4. Implement proper transaction handling +5. Replace hard-coded values with setup table + +[Include the Phase 2 code] +``` + +**Expected Result**: + +```al +/// +/// Manages sales order processing including validation, inventory checking, and discount application +/// +codeunit 50100 "Sales Order Management" +{ + var + InsufficientInventoryErr: Label 'Insufficient inventory for item %1. Available: %2, Required: %3'; + CustomerBlockedErr: Label 'Customer %1 is blocked and cannot place orders.'; + OrderProcessedMsg: Label 'Successfully processed order %1 with total amount %2.'; + + /// + /// Processes a sales order by validating customer, checking inventory, and applying discounts + /// + /// The sales order number to process + procedure ProcessSalesOrder(DocumentNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + TotalAmount: Decimal; + begin + if DocumentNo = '' then + Error('Document number cannot be empty.'); + + if not SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then + Error('Sales order %1 not found.', DocumentNo); + + ValidateCustomer(SalesHeader); + CheckInventoryAvailability(SalesHeader); + TotalAmount := CalculateOrderTotal(SalesHeader); + + if ShouldApplyLargeOrderDiscount(TotalAmount) then + ApplyLargeOrderDiscount(SalesHeader); + + LogOrderProcessing(DocumentNo, TotalAmount); + Message(OrderProcessedMsg, DocumentNo, Format(TotalAmount, 0, '')); + end; + + local procedure ValidateCustomer(SalesHeader: Record "Sales Header") + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Blocked); + + if not Customer.Get(SalesHeader."Sell-to Customer No.") then + Error('Customer %1 not found.', SalesHeader."Sell-to Customer No."); + + if Customer.Blocked <> Customer.Blocked::" " then + Error(CustomerBlockedErr, Customer."No."); + end; + + local procedure CheckInventoryAvailability(SalesHeader: Record "Sales Header") + var + SalesLine: Record "Sales Line"; + Item: Record Item; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.SetRange(Type, SalesLine.Type::Item); + SalesLine.SetLoadFields("No.", Quantity); + + if SalesLine.FindSet() then begin + Item.SetLoadFields(Inventory); + repeat + if Item.Get(SalesLine."No.") then + if Item.Inventory < SalesLine.Quantity then + Error(InsufficientInventoryErr, Item."No.", Item.Inventory, SalesLine.Quantity); + until SalesLine.Next() = 0; + end; + end; + + local procedure CalculateOrderTotal(SalesHeader: Record "Sales Header"): Decimal + var + SalesLine: Record "Sales Line"; + TotalAmount: Decimal; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.SetLoadFields("Line Amount"); + + if SalesLine.FindSet() then + repeat + TotalAmount += SalesLine."Line Amount"; + until SalesLine.Next() = 0; + + exit(TotalAmount); + end; + + local procedure ShouldApplyLargeOrderDiscount(TotalAmount: Decimal): Boolean + var + SalesSetup: Record "Sales & Receivables Setup"; + begin + SalesSetup.Get(); + exit(TotalAmount > SalesSetup."Large Order Threshold"); + end; + + local procedure ApplyLargeOrderDiscount(SalesHeader: Record "Sales Header") + var + SalesLine: Record "Sales Line"; + SalesSetup: Record "Sales & Receivables Setup"; + begin + SalesSetup.Get(); + + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.ModifyAll("Line Discount %", SalesSetup."Large Order Discount %", true); + end; + + local procedure LogOrderProcessing(DocumentNo: Code[20]; TotalAmount: Decimal) + var + Telemetry: Codeunit Telemetry; + CustomDimensions: Dictionary of [Text, Text]; + begin + CustomDimensions.Add('DocumentNo', DocumentNo); + CustomDimensions.Add('TotalAmount', Format(TotalAmount)); + + Telemetry.LogMessage('SALESORD-001', 'Sales order processed successfully', + Verbosity::Normal, DataClassification::SystemMetadata, + TelemetryScope::ExtensionPublisher, CustomDimensions); + end; +} +``` + +### Step 10: Update Tests for Behavioral Changes + +Now that the code is cleaner, you may want to improve behavior (e.g., Error() instead of Message()): + +**Prompt**: + +``` +I want to change the behavior to use Error() instead of Message() for validation failures. + +First, update the tests to expect these errors: +- TestBlockedCustomerError should expect an error +- Update assertions to use asserterror and Assert.ExpectedError + +Then show the code changes needed. +``` + +### Step 11: Expand Test Coverage + +**Prompt**: + +``` +Now that refactoring is complete, add more comprehensive tests for edge cases. + +Include tests for: +- Happy path: successful processing +- Customer validation errors +- Inventory availability checks +- Large order discount application +- Edge cases: empty document number, non-existent order + +Use the AL test framework with proper setup and teardown. +``` + +**Expected Test Codeunit**: + +```al +codeunit 50101 "Sales Order Management Tests" +{ + Subtype = Test; + + var + Assert: Codeunit "Library Assert"; + LibrarySales: Codeunit "Library - Sales"; + LibraryInventory: Codeunit "Library - Inventory"; + + [Test] + procedure TestSuccessfulOrderProcessing() + var + SalesHeader: Record "Sales Header"; + SalesOrderMgmt: Codeunit "Sales Order Management"; + begin + // [GIVEN] A valid sales order with sufficient inventory + CreateSalesOrderWithInventory(SalesHeader); + + // [WHEN] Processing the order + SalesOrderMgmt.ProcessSalesOrder(SalesHeader."No."); + + // [THEN] No error is raised + // Verified by test not failing + end; + + [Test] + procedure TestBlockedCustomerError() + var + SalesHeader: Record "Sales Header"; + Customer: Record Customer; + SalesOrderMgmt: Codeunit "Sales Order Management"; + begin + // [GIVEN] A sales order for a blocked customer + CreateSalesOrderWithBlockedCustomer(SalesHeader, Customer); + + // [WHEN] Processing the order + asserterror SalesOrderMgmt.ProcessSalesOrder(SalesHeader."No."); + + // [THEN] Error is raised about blocked customer + Assert.ExpectedError(StrSubstNo('Customer %1 is blocked', Customer."No.")); + end; + + [Test] + procedure TestInsufficientInventoryError() + var + SalesHeader: Record "Sales Header"; + Item: Record Item; + SalesOrderMgmt: Codeunit "Sales Order Management"; + begin + // [GIVEN] A sales order with more quantity than available inventory + CreateSalesOrderWithInsufficientInventory(SalesHeader, Item); + + // [WHEN] Processing the order + asserterror SalesOrderMgmt.ProcessSalesOrder(SalesHeader."No."); + + // [THEN] Error is raised about insufficient inventory + Assert.ExpectedError('Insufficient inventory'); + end; + + [Test] + procedure TestLargeOrderDiscountApplied() + var + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + SalesSetup: Record "Sales & Receivables Setup"; + SalesOrderMgmt: Codeunit "Sales Order Management"; + ExpectedDiscount: Decimal; + begin + // [GIVEN] A large order that qualifies for discount + SalesSetup.Get(); + ExpectedDiscount := SalesSetup."Large Order Discount %"; + CreateLargeSalesOrder(SalesHeader); + + // [WHEN] Processing the order + SalesOrderMgmt.ProcessSalesOrder(SalesHeader."No."); + + // [THEN] Discount is applied to all lines + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.FindSet(); + repeat + Assert.AreEqual(ExpectedDiscount, SalesLine."Line Discount %", 'Discount not applied correctly'); + until SalesLine.Next() = 0; + end; + + local procedure CreateSalesOrderWithInventory(var SalesHeader: Record "Sales Header") + var + Item: Record Item; + SalesLine: Record "Sales Line"; + begin + LibraryInventory.CreateItem(Item); + Item.Inventory := 100; + Item.Modify(); + + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, LibrarySales.CreateCustomerNo()); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 10); + end; + + // Additional helper procedures... +} +``` + +## Review Points: What to Check + +After each refactoring phase, verify: + +### ✅ Functionality Preserved + +- [ ] All original functionality still works +- [ ] No regressions introduced +- [ ] Tests pass (or create tests first!) +- [ ] Edge cases still handled + +### ✅ Code Quality Improved + +- [ ] More readable and maintainable +- [ ] Better structure and organization +- [ ] Clearer naming and documentation +- [ ] Proper error handling + +### ✅ Performance Not Degraded + +- [ ] Database operations optimized +- [ ] No unnecessary loops or queries +- [ ] Proper use of SetLoadFields +- [ ] Efficient algorithms + +### ✅ Modern Practices Applied + +- [ ] Current AL syntax and APIs +- [ ] No deprecated features +- [ ] Proper telemetry +- [ ] Good separation of concerns + +## Advanced Refactoring Patterns + +### Extract Interface for Testability + +**Prompt**: + +``` +Refactor this codeunit to use interfaces for dependencies, making it more testable. + +Extract interfaces for: +- Customer validation +- Inventory checking +- Discount calculation + +This will allow us to mock these dependencies in tests. +``` + +### Convert to Event-Driven Architecture + +**Prompt**: + +``` +Refactor this code to use Business Central events: + +1. Publish events for: + - Before order processing + - After order validation + - Before discount application + - After order processing + +2. This allows other extensions to hook into the process +``` + +### Add Batch Processing Support + +**Prompt**: + +``` +Extend this codeunit to support batch processing of multiple orders. + +Include: +- Progress tracking +- Error handling per order (don't fail entire batch) +- Summary reporting +- Performance optimization for bulk operations +``` + +## Common Refactoring Challenges + +### Challenge 1: Unknown Business Logic + +**Problem**: Code has complex logic without documentation + +**Solution**: + +``` +Analyze this code and explain what business logic it implements: +[paste complex code] + +Then suggest how to make the logic clearer through refactoring. +``` + +### Challenge 2: Tightly Coupled Code + +**Problem**: Code has many dependencies that are hard to untangle + +**Solution**: + +``` +This code is tightly coupled. Create a refactoring plan to: +1. Identify dependencies +2. Extract interfaces +3. Use dependency injection +4. Make code more modular +``` + +### Challenge 3: Large Procedures + +**Problem**: Single procedure doing too many things + +**Solution**: + +``` +This procedure is too large and complex. +Apply the Single Responsibility Principle to break it into smaller procedures. +Each procedure should have one clear purpose. +``` + +## Best Practices for Refactoring with AI + +### 1. Always Write Tests First + +{{% alert title="Golden Rule" color="primary" %}} +**Never refactor without tests.** Tests are your safety net. Write them first, run them, then refactor. +{{% /alert %}} + +``` +Before we start refactoring, let's write tests that lock in the current behavior. +Even if the current behavior isn't perfect, we need to know if we change it. +``` + +### 2. Refactor in Small Steps + +Don't try to refactor everything at once. Use incremental changes: + +``` +Let's refactor this code in three phases: +Phase 1: Just improve naming and documentation +[Run tests - should pass] +Phase 2: Extract methods +[Run tests - should pass] +Phase 3: Modernize APIs +[Run tests - should pass] +``` + +### 3. Run Tests After Every Change + +``` +I've completed the naming refactoring. +Let me run the tests to make sure nothing broke. + +[Run tests] + +Great, tests pass. Now let's proceed to Phase 2. +``` + +### 3. Use Git Commits for Each Phase + +``` +We've completed Phase 1: Safe Refactorings. +Before moving to Phase 2, I'll commit these changes. +Suggest a good commit message for these refactorings. +``` + +### 4. Document Why, Not Just What + +``` +For each major refactoring, add a comment explaining WHY the change was made: +- Why was the old approach problematic? +- What does the new approach solve? +- Are there trade-offs? +``` + +### 5. Keep Performance in Mind + +``` +As we refactor this code, let's ensure we don't hurt performance. +For each database operation change, explain the performance implications. +``` + +## Practice Exercise + +Refactor this legacy code: + +```al +codeunit 50200 "Item Price Calculator" +{ + procedure CalcPrice(IN: Code[20]; CU: Code[10]; QT: Decimal): Decimal + var + I: Record Item; + C: Record Customer; + P: Decimal; + begin + I.Get(IN); + P := I."Unit Price"; + + if QT > 100 then + P := P * 0.9; + + if CU <> '' then begin + C.Get(CU); + if C."Customer Price Group" = 'VIP' then + P := P * 0.95; + end; + + exit(P); + end; +} +``` + +**Your Tasks**: + +1. Assess the code and list issues +2. Create a refactoring plan +3. Apply naming improvements +4. Extract methods for each responsibility +5. Add proper documentation and error handling +6. Create tests +7. Add telemetry + +## Next Steps + +- Learn about [writing tests](../testing) for your refactored code +- See how [code review](../code-review) catches refactoring issues +- Explore [adding telemetry](../telemetry) to monitor refactored code diff --git a/content/docs/agentic-coding/GettingMore/testing.md b/content/docs/agentic-coding/GettingMore/testing.md new file mode 100644 index 00000000..3c9cfae7 --- /dev/null +++ b/content/docs/agentic-coding/GettingMore/testing.md @@ -0,0 +1,902 @@ +--- +title: "Writing Tests" +linkTitle: "Testing" +weight: 5 +description: > + Use AI to create comprehensive test coverage for your AL code +--- + +## Scenario + +You've developed new features for your Business Central extension, but you need comprehensive test coverage to: + +- Ensure code works as expected +- Prevent regressions when making changes +- Document expected behavior +- Enable confident refactoring +- Meet quality standards + +Writing tests manually is time-consuming, and you want to use AI to accelerate the process while maintaining test quality. + +## Goal + +Use AI to help you: + +- Generate unit tests for individual procedures +- Create integration tests for complex workflows +- Design test data and scenarios +- Write test helpers and fixtures +- Create mock objects for dependencies +- Implement data-driven tests + +## The Code to Test + +Here's a codeunit that needs test coverage: + +```al +codeunit 50100 "Order Discount Manager" +{ + procedure CalculateDiscount(var SalesHeader: Record "Sales Header"): Decimal + var + Customer: Record Customer; + DiscountPct: Decimal; + begin + if not Customer.Get(SalesHeader."Sell-to Customer No.") then + Error('Customer %1 not found', SalesHeader."Sell-to Customer No."); + + DiscountPct := GetCustomerDiscount(Customer); + DiscountPct += GetVolumeDiscount(SalesHeader); + DiscountPct += GetSeasonalDiscount(); + + if DiscountPct > 50 then + DiscountPct := 50; + + exit(DiscountPct); + end; + + local procedure GetCustomerDiscount(Customer: Record Customer): Decimal + begin + case Customer."Customer Discount Group" of + 'VIP': + exit(10); + 'PREMIUM': + exit(5); + else + exit(0); + end; + end; + + local procedure GetVolumeDiscount(SalesHeader: Record "Sales Header"): Decimal + var + SalesLine: Record "Sales Line"; + TotalAmount: Decimal; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.CalcSums("Line Amount"); + TotalAmount := SalesLine."Line Amount"; + + case true of + TotalAmount >= 10000: + exit(15); + TotalAmount >= 5000: + exit(10); + TotalAmount >= 1000: + exit(5); + else + exit(0); + end; + end; + + local procedure GetSeasonalDiscount(): Decimal + begin + if Date2DMY(Today, 2) in [11, 12] then + exit(5); + exit(0); + end; +} +``` + +## Step-by-Step Testing Process + +### Step 1: Generate Basic Test Structure + +**Prompt**: + +``` +Create a test codeunit for the Order Discount Manager. + +Include: +- Proper test codeunit structure with Subtype = Test +- Setup and teardown if needed +- Test procedure stubs for all public procedures +- Use AL test framework conventions +- Include Assert codeunit +- Add test isolation attributes where needed + +[Include the codeunit code] +``` + +**Expected Result**: + +```al +codeunit 50101 "Order Discount Mgr. Tests" +{ + Subtype = Test; + + var + Assert: Codeunit "Library Assert"; + LibrarySales: Codeunit "Library - Sales"; + LibraryUtility: Codeunit "Library - Utility"; + IsInitialized: Boolean; + + [Test] + procedure TestCalculateDiscountBasic() + begin + // Test basic discount calculation + end; + + [Test] + procedure TestVIPCustomerDiscount() + begin + // Test VIP customer gets 10% discount + end; + + [Test] + procedure TestVolumeDiscountTiers() + begin + // Test volume discount calculation at different amounts + end; + + [Test] + procedure TestSeasonalDiscount() + begin + // Test seasonal discount in November/December + end; + + [Test] + procedure TestMaximumDiscountCap() + begin + // Test discount cannot exceed 50% + end; + + [Test] + procedure TestCustomerNotFoundError() + begin + // Test error when customer doesn't exist + end; + + local procedure Initialize() + begin + if IsInitialized then + exit; + + // Setup code here + IsInitialized := true; + end; +} +``` + +### Step 2: Implement Happy Path Test + +**Prompt**: + +``` +Implement the TestCalculateDiscountBasic test using the Given-When-Then pattern. + +This test should verify: +- A standard customer with a medium-size order +- Gets the expected discount percentage +- No errors are raised + +Use the Library codeunits for test data creation. +Include clear comments for each section. +``` + +**Expected Result**: + +```al +[Test] +procedure TestCalculateDiscountBasic() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; + ExpectedDiscount: Decimal; +begin + // [SCENARIO] Calculate discount for a standard order + Initialize(); + + // [GIVEN] A standard customer with no special discount group + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := ''; + Customer.Modify(); + + // [GIVEN] A sales order with total amount of 2000 (5% volume discount) + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", 2000); + + // [GIVEN] Expected discount is 5% (volume only, not in seasonal months) + ExpectedDiscount := 5; + + // [WHEN] Calculating the discount + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // [THEN] The discount matches expected value + Assert.AreEqual(ExpectedDiscount, ActualDiscount, 'Discount calculation incorrect'); +end; + +local procedure CreateSalesOrderWithAmount(CustomerNo: Code[20]; TotalAmount: Decimal): Record "Sales Header" +var + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + Item: Record Item; +begin + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, CustomerNo); + + Item."No." := LibraryUtility.GenerateRandomCode(Item.FieldNo("No."), Database::Item); + Item."Unit Price" := TotalAmount; + Item.Insert(); + + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 1); + SalesLine.Validate("Unit Price", TotalAmount); + SalesLine.Modify(true); + + exit(SalesHeader); +end; +``` + +### Step 3: Implement Error Case Tests + +**Prompt**: + +``` +Implement the TestCustomerNotFoundError test. + +This should verify: +- When an invalid customer number is used +- An appropriate error is raised +- The error message contains the customer number + +Use asserterror and Assert.ExpectedError. +``` + +**Expected Result**: + +```al +[Test] +procedure TestCustomerNotFoundError() +var + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + InvalidCustomerNo: Code[20]; +begin + // [SCENARIO] Error is raised when customer doesn't exist + Initialize(); + + // [GIVEN] A sales header with non-existent customer + InvalidCustomerNo := 'INVALID123'; + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, ''); + SalesHeader."Sell-to Customer No." := InvalidCustomerNo; + SalesHeader.Modify(); + + // [WHEN] Calculating discount for non-existent customer + asserterror OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // [THEN] Appropriate error is raised with customer number + Assert.ExpectedError(StrSubstNo('Customer %1 not found', InvalidCustomerNo)); +end; +``` + +### Step 4: Implement Data-Driven Tests + +**Prompt**: + +``` +Create a comprehensive test for all customer discount tiers (VIP, PREMIUM, standard). + +Use a data-driven approach where one test function tests all scenarios. +Create a local helper that takes customer group and expected discount as parameters. + +Test these scenarios: +- VIP customer: 10% discount +- PREMIUM customer: 5% discount +- Standard customer: 0% discount +- Empty group: 0% discount +``` + +**Expected Result**: + +```al +[Test] +procedure TestCustomerDiscountTiers() +begin + // [SCENARIO] Different customer types receive appropriate discounts + Initialize(); + + // [GIVEN] [WHEN] [THEN] VIP customers get 10% discount + VerifyCustomerTypeDiscount('VIP', 10); + + // [GIVEN] [WHEN] [THEN] Premium customers get 5% discount + VerifyCustomerTypeDiscount('PREMIUM', 5); + + // [GIVEN] [WHEN] [THEN] Standard customers get 0% discount + VerifyCustomerTypeDiscount('STANDARD', 0); + + // [GIVEN] [WHEN] [THEN] Customers with no group get 0% discount + VerifyCustomerTypeDiscount('', 0); +end; + +local procedure VerifyCustomerTypeDiscount(CustomerGroup: Code[10]; ExpectedDiscount: Decimal) +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; +begin + // Create customer with specific group + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := CustomerGroup; + Customer.Modify(); + + // Create order with amount below volume discount threshold (< 1000) + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", 500); + + // Calculate discount + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // Verify expected discount + Assert.AreEqual(ExpectedDiscount, ActualDiscount, + StrSubstNo('Incorrect discount for customer group %1', CustomerGroup)); +end; +``` + +### Step 5: Test Edge Cases and Boundaries + +**Prompt**: + +``` +Create tests for volume discount boundary conditions. + +Test these specific amounts: +- 999: Just below 1000 threshold (expect 0% volume discount) +- 1000: Exactly at threshold (expect 5% volume discount) +- 4999: Just below 5000 threshold (expect 5% volume discount) +- 5000: Exactly at threshold (expect 10% volume discount) +- 9999: Just below 10000 threshold (expect 10% volume discount) +- 10000: Exactly at threshold (expect 15% volume discount) + +Use descriptive test names and clear assertions. +``` + +**Expected Result**: + +```al +[Test] +procedure TestVolumeDiscountBoundaries() +begin + // [SCENARIO] Volume discounts applied at exact thresholds + Initialize(); + + // Just below first threshold + VerifyVolumeDiscount(999, 0, 'Below 1000 threshold'); + + // Exactly at first threshold + VerifyVolumeDiscount(1000, 5, 'At 1000 threshold'); + + // Between first and second threshold + VerifyVolumeDiscount(4999, 5, 'Below 5000 threshold'); + + // Exactly at second threshold + VerifyVolumeDiscount(5000, 10, 'At 5000 threshold'); + + // Between second and third threshold + VerifyVolumeDiscount(9999, 10, 'Below 10000 threshold'); + + // Exactly at third threshold + VerifyVolumeDiscount(10000, 15, 'At 10000 threshold'); +end; + +local procedure VerifyVolumeDiscount(OrderAmount: Decimal; ExpectedVolumeDiscount: Decimal; Scenario: Text) +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; +begin + // Create standard customer (no customer discount) + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := ''; + Customer.Modify(); + + // Create order with specific amount + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", OrderAmount); + + // Calculate discount (assuming not in seasonal months) + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // Verify volume discount + Assert.AreEqual(ExpectedVolumeDiscount, ActualDiscount, + StrSubstNo('Incorrect volume discount for %1 (amount: %2)', Scenario, OrderAmount)); +end; +``` + +### Step 6: Test Maximum Discount Cap + +**Prompt**: + +``` +Create a test that verifies the 50% maximum discount cap. + +Test scenario: +- VIP customer (10% discount) +- Large order (15% volume discount) +- Seasonal period (5% discount) +- Total would be 30%, which is under cap - verify this works +- Then test a scenario that would exceed 50% and verify it's capped + +Consider: What combination would exceed 50%? We may need to modify setup or mock GetSeasonalDiscount. +``` + +**Expected Result**: + +```al +[Test] +procedure TestMaximumDiscountCap() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; +begin + // [SCENARIO] Discount is capped at 50% even when combined discounts exceed it + Initialize(); + + // [GIVEN] VIP customer (10% discount) + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := 'VIP'; + Customer.Modify(); + + // [GIVEN] Large order (15% volume discount) + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", 10000); + + // [GIVEN] If testing in Nov/Dec, total would be 10+15+5=30% (under cap) + // [GIVEN] To test cap, we need a scenario that would exceed 50% + // Note: With current business logic, max is 30%, so cap isn't reached + + // [WHEN] Calculating discount + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // [THEN] Discount doesn't exceed 50% + Assert.IsTrue(ActualDiscount <= 50, 'Discount exceeds maximum allowed 50%'); +end; + +[Test] +procedure TestDiscountCapWithMultipleDiscounts() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; +begin + // [SCENARIO] Combined discounts that would exceed 50% are capped + // [NOTE] This test may need the business logic modified to make it relevant + // Current max possible: 10% (VIP) + 15% (volume) + 5% (seasonal) = 30% + + Initialize(); + + // Create scenario with maximum possible discounts + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := 'VIP'; + Customer.Modify(); + + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", 10000); + + // Calculate discount + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // Verify cap is applied if needed + Assert.IsTrue(ActualDiscount <= 50, 'Discount cap not enforced'); +end; +``` + +### Step 7: Test Time-Dependent Logic + +**Prompt**: + +``` +Create tests for the seasonal discount that depends on the current date. + +Since GetSeasonalDiscount uses Today, we need to: +1. Test behavior for November (month 11) +2. Test behavior for December (month 12) +3. Test behavior for other months + +Explain how to handle date-dependent tests in AL. +Options: +- Make the date a parameter (refactor code) +- Use test isolation / mocking +- Create tests that work year-round +``` + +**Expected Result**: + +```al +[Test] +procedure TestSeasonalDiscountInNovember() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; + CurrentMonth: Integer; +begin + // [SCENARIO] Seasonal discount is applied in November + // [NOTE] This test only passes when run in November + + Initialize(); + + CurrentMonth := Date2DMY(Today, 2); + if CurrentMonth <> 11 then + exit; // Skip test if not November + + // [GIVEN] Standard customer with small order (no other discounts) + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := ''; + Customer.Modify(); + + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", 500); + + // [WHEN] Calculating discount in November + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // [THEN] 5% seasonal discount is applied + Assert.AreEqual(5, ActualDiscount, 'Seasonal discount not applied in November'); +end; + +[Test] +procedure TestNoSeasonalDiscountInJanuary() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; + CurrentMonth: Integer; +begin + // [SCENARIO] No seasonal discount in non-holiday months + + Initialize(); + + CurrentMonth := Date2DMY(Today, 2); + if CurrentMonth in [11, 12] then + exit; // Skip test if in seasonal period + + // [GIVEN] Standard customer with small order + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := ''; + Customer.Modify(); + + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", 500); + + // [WHEN] Calculating discount outside seasonal period + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // [THEN] No discount is applied + Assert.AreEqual(0, ActualDiscount, 'Unexpected discount outside seasonal period'); +end; + +// [RECOMMENDATION] Refactor GetSeasonalDiscount to accept date parameter +// This would make testing much easier and more reliable: +// local procedure GetSeasonalDiscount(CheckDate: Date): Decimal +``` + +### Step 8: Integration Test + +**Prompt**: + +``` +Create an integration test that tests the complete workflow: + +1. Create a new customer with VIP status +2. Create a sales order for that customer +3. Add multiple lines totaling over 10,000 +4. Calculate the discount +5. Verify all discount types are correctly combined +6. Verify the discount is applied to the sales header + +This test should verify the codeunit works correctly with real BC data and tables. +``` + +**Expected Result**: + +```al +[Test] +procedure TestCompleteDiscountWorkflow() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + Item: Record Item; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ExpectedDiscount: Decimal; + ActualDiscount: Decimal; + CurrentMonth: Integer; +begin + // [SCENARIO] Complete workflow of discount calculation and application + Initialize(); + + // [GIVEN] A VIP customer + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := 'VIP'; + Customer.Modify(); + + // [GIVEN] A sales order with multiple lines totaling 12,000 + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No."); + + // Add three items + CreateItemAndSalesLine(SalesHeader, 5000); + CreateItemAndSalesLine(SalesHeader, 4000); + CreateItemAndSalesLine(SalesHeader, 3000); + + // [GIVEN] Expected discount calculation: + // - VIP customer: 10% + // - Volume (>10000): 15% + // - Seasonal (if Nov/Dec): 5% + CurrentMonth := Date2DMY(Today, 2); + if CurrentMonth in [11, 12] then + ExpectedDiscount := 30 // 10 + 15 + 5 + else + ExpectedDiscount := 25; // 10 + 15 + + // [WHEN] Calculating the discount + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // [THEN] The correct combined discount is calculated + Assert.AreEqual(ExpectedDiscount, ActualDiscount, + 'Complete discount calculation incorrect'); + + // [THEN] Verify order total is correct + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.CalcSums("Line Amount"); + Assert.AreEqual(12000, SalesLine."Line Amount", 'Order total incorrect'); +end; + +local procedure CreateItemAndSalesLine(SalesHeader: Record "Sales Header"; Amount: Decimal) +var + Item: Record Item; + SalesLine: Record "Sales Line"; +begin + Item."No." := LibraryUtility.GenerateRandomCode(Item.FieldNo("No."), Database::Item); + Item."Unit Price" := Amount; + Item.Insert(); + + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 1); + SalesLine.Validate("Unit Price", Amount); + SalesLine.Modify(true); +end; +``` + +## Review Points: What to Check in Tests + +### ✅ Test Quality + +- [ ] Tests are independent (can run in any order) +- [ ] Tests are repeatable (same result every time) +- [ ] Tests have clear Given-When-Then structure +- [ ] Test names clearly describe what is being tested +- [ ] Assertions have descriptive error messages + +### ✅ Test Coverage + +- [ ] Happy path scenarios covered +- [ ] Error cases tested +- [ ] Edge cases and boundaries tested +- [ ] All public procedures have tests +- [ ] Integration scenarios tested + +### ✅ Test Maintainability + +- [ ] Helper methods reduce duplication +- [ ] Test data creation is centralized +- [ ] Tests are easy to understand +- [ ] Tests don't depend on specific data +- [ ] Setup and teardown properly implemented + +### ✅ Test Performance + +- [ ] Tests run quickly +- [ ] Minimal database operations +- [ ] Proper use of test isolation +- [ ] No unnecessary waits or delays + +## Advanced Testing Patterns + +### Pattern 1: Test Fixtures + +**Prompt**: + +``` +Create a test fixture class for sales orders that provides: +- Standard order (customer with no discounts, small amount) +- VIP order (VIP customer, medium amount) +- Large order (standard customer, large amount) +- Complex order (VIP customer, large amount, multiple lines) + +These fixtures should be reusable across all tests. +``` + +### Pattern 2: Mock Objects + +**Prompt**: + +``` +The GetSeasonalDiscount procedure is hard to test because it depends on Today. + +Refactor the code to use dependency injection: +1. Create an interface for date provider +2. Inject the date provider into the codeunit +3. Create a mock date provider for testing +4. Show how to test with different dates +``` + +### Pattern 3: Test Data Builders + +**Prompt**: + +``` +Create a fluent test data builder for sales orders: + +SalesOrderBuilder + .WithCustomer(CustomerNo) + .WithVIPStatus() + .WithLine(ItemNo, Quantity, Price) + .WithTotalAmount(Amount) + .Build() + +This makes test data creation more readable and flexible. +``` + +## Best Practices for AI-Assisted Testing + +### 1. Start with Test Cases, Then Generate + +``` +Before generating test code, help me identify all test cases for this procedure: +- What scenarios should be tested? +- What are the edge cases? +- What error conditions exist? +- What are the boundary conditions? + +[Include procedure code] +``` + +### 2. Generate Tests in Batches + +``` +Generate tests for these three related procedures together so they share test helpers: +- CalculateDiscount +- ApplyDiscount +- ValidateDiscount +``` + +### 3. Request Explanatory Comments + +``` +Generate the test with detailed comments explaining: +- Why this test case is important +- What could go wrong if this test didn't exist +- Any gotchas or special considerations +``` + +### 4. Ask for Test Improvements + +``` +Review this test I wrote. Suggest improvements for: +- Clarity and readability +- Better assertions +- Edge cases I might have missed +- Ways to make it more maintainable +``` + +### 5. Generate Test Documentation + +``` +Create documentation for this test suite explaining: +- What is being tested +- Test coverage summary +- How to run the tests +- How to add new tests +- Known limitations +``` + +## Common Testing Challenges + +### Challenge 1: Testing Private Methods + +**Problem**: Local procedures can't be tested directly + +**Solution**: + +``` +I need to test this local procedure. Options: +1. Make it public (if appropriate) +2. Test it indirectly through public procedures +3. Extract to a separate testable codeunit + +Which approach is best for this scenario? [Include code] +``` + +### Challenge 2: Testing Database Operations + +**Problem**: Tests that modify database are slow and fragile + +**Solution**: + +``` +This codeunit performs database operations. Help me: +1. Identify which operations need real database +2. Which can be mocked or isolated +3. Create a testing strategy that balances coverage and speed +``` + +### Challenge 3: Testing External Dependencies + +**Problem**: Code calls external services or APIs + +**Solution**: + +``` +This code calls an external API. Create: +1. An interface for the API +2. A mock implementation for testing +3. Tests using the mock +4. Integration tests for the real API (marked for manual runs) +``` + +## Practice Exercise + +Write comprehensive tests for this codeunit: + +```al +codeunit 50200 "Credit Limit Checker" +{ + procedure CheckCreditLimit(CustomerNo: Code[20]; NewOrderAmount: Decimal): Boolean + var + Customer: Record Customer; + CustLedgerEntry: Record "Cust. Ledger Entry"; + TotalOutstanding: Decimal; + begin + if not Customer.Get(CustomerNo) then + Error('Customer not found'); + + Customer.CalcFields("Balance (LCY)"); + TotalOutstanding := Customer."Balance (LCY)" + NewOrderAmount; + + if Customer."Credit Limit (LCY)" = 0 then + exit(true); + + exit(TotalOutstanding <= Customer."Credit Limit (LCY)"); + end; +} +``` + +**Your Tasks**: + +1. List all test scenarios +2. Create test codeunit structure +3. Implement tests for: + - Customer not found + - No credit limit (unlimited) + - Within credit limit + - Exactly at credit limit + - Over credit limit + - Edge cases +4. Add integration test +5. Review and improve tests + +## Next Steps + +- Learn how [code review](../code-review) can verify test quality +- See how [refactoring](../refactoring) benefits from good tests +- Explore [documentation](../documentation) for test procedures