Most Excel VBA developers skip automated testing entirely — and pay for it with production bugs, regression failures, and hours of debugging. This lesson walks you through building a complete unit testing framework in pure VBA, with a rich assertion library, color-coded test reports, and integration patterns that catch failures before they reach real data.

Here's a scenario that will feel uncomfortably familiar if you've been writing VBA for more than a few months: It's Thursday afternoon. You've just made a "small fix" to the macro that generates the monthly revenue report. You test it on your sample data, it looks right, you deploy it. Friday morning, finance calls — the totals are wrong, three worksheets are blank, and the PDF that was emailed to the entire leadership team has yesterday's numbers. The problem? When you fixed one calculation, you silently broke the data-cleaning step that runs three subroutines earlier.
This is the regression problem, and it's endemic to Excel VBA development because almost nobody builds automated tests. Professional software developers have had unit testing frameworks — tools like JUnit, pytest, and RSpec — for decades. VBA developers have typically relied on manually running macros and eyeballing results. That gap between "it looked fine when I checked" and "it is provably correct according to 47 automated assertions" is where production bugs live.
By the end of this lesson, you will have built a fully functional VBA testing framework entirely in Excel and VBA — no external dependencies, no COM add-ins required. You'll be able to write unit tests that run in seconds, get a color-coded pass/fail report on a dedicated worksheet, and catch regressions automatically before any macro touches production data.
What you'll learn:
Workbook_Open or ribbon buttons so tests run before deploymentThis is an expert-level lesson. You should be comfortable with:
On Error statements (Error Handling and Debugging VBA Code Like a Pro has the full picture)Before we write a single line of test code, let's be precise about what we're trying to solve. Manual testing in VBA typically looks like this: you run your macro, look at the spreadsheet, think "yep, that looks about right," and move on. This fails in three distinct ways.
First, it doesn't scale. When you have a macro system with fifteen subroutines that each do different things, manually verifying every output after every change takes longer than making the changes themselves. So you skip it. And that's when bugs get introduced.
Second, human visual inspection misses subtle errors. A value of 1234567.89 looks fine in a cell. A value of 1234567.8900001 — the result of floating-point arithmetic going slightly wrong — also looks fine. An automated test with a tolerance assertion will catch it. Your eyes won't.
Third, manual testing doesn't prevent regressions. A regression is when a change that fixes one thing breaks something that was already working. The only reliable way to catch regressions is to have a suite of tests that validates the entire system's behavior every time you change any part of it.
What we're building is specifically a unit testing framework — meaning we test individual functions and subroutines in isolation, with controlled inputs and verified outputs. This is different from integration testing (testing the whole workflow end-to-end) or user acceptance testing (humans trying things out). Unit tests are fast, repeatable, and surgical. They tell you exactly which component broke.
Key insight: The reason most VBA codebases are hard to test is not that VBA lacks testing tools — it's that the code is written in a way that makes testing impossible. Tightly coupled code that reads directly from worksheets, writes results into globals, and does everything in one giant subroutine cannot be unit tested. Building a test framework will force you to write better, more modular code. That is a feature, not a side effect.
A minimal but complete testing framework has three parts:
TestResult class — stores the outcome (pass/fail), the test name, and a message explaining what went wrongTestSuite class — collects TestResult objects, runs assertions, and manages state during a test runTestRunner module — discovers and runs all test subroutines, then renders the results to a worksheetThis mirrors the architecture of professional frameworks like JUnit and NUnit, adapted for the constraints of VBA. We'll build each piece from scratch.
Create a new Class Module in the VBA editor (Insert → Class Module) and name it TestResult.
' TestResult Class Module
Option Explicit
Private mTestName As String
Private mPassed As Boolean
Private mMessage As String
Private mCategory As String
Public Property Get TestName() As String
TestName = mTestName
End Property
Public Property Let TestName(val As String)
mTestName = val
End Property
Public Property Get Passed() As Boolean
Passed = mPassed
End Property
Public Property Let Passed(val As Boolean)
mPassed = val
End Property
Public Property Get Message() As String
Message = mMessage
End Property
Public Property Let Message(val As String)
mMessage = val
End Property
Public Property Get Category() As String
Category = mCategory
End Property
Public Property Let Category(val As String)
mCategory = val
End Property
Straightforward so far. Each TestResult is an immutable-ish record of what happened during one assertion.
This is the heart of the framework. Create another Class Module named TestSuite.
' TestSuite Class Module
Option Explicit
Private mResults As Collection
Private mCurrentTest As String
Private mCurrentCat As String
Private mPassCount As Long
Private mFailCount As Long
Private Sub Class_Initialize()
Set mResults = New Collection
mPassCount = 0
mFailCount = 0
End Sub
' ── Test Naming ────────────────────────────────────────────────────
Public Sub BeginTest(testName As String, Optional category As String = "General")
mCurrentTest = testName
mCurrentCat = category
End Sub
' ── Core Recording ────────────────────────────────────────────────
Private Sub RecordResult(passed As Boolean, message As String)
Dim r As New TestResult
r.TestName = mCurrentTest
r.Passed = passed
r.Category = mCurrentCat
If passed Then
r.Message = "PASS"
mPassCount = mPassCount + 1
Else
r.Message = "FAIL: " & message
mFailCount = mFailCount + 1
End If
mResults.Add r
End Sub
' ── Assertion Library ─────────────────────────────────────────────
' Strict equality for strings, booleans, integers
Public Sub AssertEqual(expected As Variant, actual As Variant)
If expected = actual Then
RecordResult True, ""
Else
RecordResult False, "Expected [" & expected & "] but got [" & actual & "]"
End If
End Sub
' Numeric equality within a tolerance band (essential for floating-point)
Public Sub AssertNearlyEqual(expected As Double, actual As Double, _
Optional tolerance As Double = 0.0001)
If Abs(expected - actual) <= tolerance Then
RecordResult True, ""
Else
RecordResult False, "Expected " & expected & " ± " & tolerance & _
" but got " & actual
End If
End Sub
' Boolean assertions
Public Sub AssertTrue(condition As Boolean)
RecordResult condition, "Expected True but condition was False"
End Sub
Public Sub AssertFalse(condition As Boolean)
RecordResult Not condition, "Expected False but condition was True"
End Sub
' Type checking
Public Sub AssertIsNumeric(val As Variant)
RecordResult IsNumeric(val), "Expected numeric value but got [" & _
TypeName(val) & "] " & CStr(val)
End Sub
' Null / empty checking
Public Sub AssertNotEmpty(val As Variant)
Dim isEmpty As Boolean
isEmpty = (IsEmpty(val) Or IsNull(val) Or (VarType(val) = vbString And Len(val) = 0))
RecordResult Not isEmpty, "Expected non-empty value but got empty/null"
End Sub
' Range value assertion — checks a specific cell's value
Public Sub AssertCellEquals(ws As Worksheet, cellAddress As String, expected As Variant)
Dim actual As Variant
actual = ws.Range(cellAddress).Value
If actual = expected Then
RecordResult True, ""
Else
RecordResult False, "Cell " & cellAddress & ": Expected [" & _
expected & "] but got [" & actual & "]"
End If
End Sub
' Range count assertion — checks how many rows a data range has
Public Sub AssertRowCount(ws As Worksheet, tableRange As String, expectedRows As Long)
Dim rng As Range
Set rng = ws.Range(tableRange)
Dim actualRows As Long
actualRows = rng.Rows.Count
If actualRows = expectedRows Then
RecordResult True, ""
Else
RecordResult False, "Range " & tableRange & ": Expected " & _
expectedRows & " rows but got " & actualRows
End If
End Sub
' Error code assertion — verifies that a cell contains an Excel error
Public Sub AssertCellIsError(ws As Worksheet, cellAddress As String)
Dim val As Variant
val = ws.Range(cellAddress).Value
RecordResult IsError(val), "Expected error in " & cellAddress & _
" but got [" & CStr(val) & "]"
End Sub
' ── Summary Properties ────────────────────────────────────────────
Public Property Get Results() As Collection
Set Results = mResults
End Property
Public Property Get PassCount() As Long
PassCount = mPassCount
End Property
Public Property Get FailCount() As Long
FailCount = mFailCount
End Property
Public Property Get TotalCount() As Long
TotalCount = mPassCount + mFailCount
End Property
Notice what we've built here: a library of different assertion types for different data kinds. This matters because a generic AssertEqual comparing two doubles will fail due to floating-point representation errors — 0.1 + 0.2 is not exactly 0.3 in binary arithmetic. AssertNearlyEqual handles that reality.
Warning: Never use
AssertEqualto compare two currency or percentage calculations that went through arithmetic operations. Always useAssertNearlyEqualwith an appropriate tolerance. For financial figures rounded to two decimal places, a tolerance of0.005is usually appropriate.
The TestRunner is a standard module (not a class) that orchestrates everything. It discovers test subroutines, runs them, and renders a color-coded report.
Create a standard module named TestRunner:
' TestRunner Module
Option Explicit
' ── Constants for the report worksheet ──────────────────────────────
Private Const REPORT_SHEET_NAME As String = "TestResults"
Private Const COL_CATEGORY As Integer = 1
Private Const COL_TEST_NAME As Integer = 2
Private Const COL_STATUS As Integer = 3
Private Const COL_MESSAGE As Integer = 4
' ── Colors ──────────────────────────────────────────────────────────
Private Const COLOR_PASS As Long = 13826048 ' Dark green
Private Const COLOR_FAIL As Long = 255 ' Red
Private Const COLOR_HEADER As Long = 4210752 ' Dark gray
Private Const COLOR_PASS_BG As Long = 13434828 ' Light green
Private Const COLOR_FAIL_BG As Long = 16755370 ' Light red/salmon
' ── Main Entry Point ────────────────────────────────────────────────
Public Sub RunAllTests()
Dim suite As New TestSuite
' ── Register your test subroutines here ──────────────────────────
Call Test_DataCleaningFunctions(suite)
Call Test_RevenueCalculations(suite)
Call Test_WorksheetOutputValidation(suite)
Call Test_DateHelpers(suite)
' Add more test subs as your suite grows...
' ── Render the report ────────────────────────────────────────────
Call RenderReport(suite)
' ── Optional: surface a summary message ──────────────────────────
Dim msg As String
If suite.FailCount = 0 Then
msg = "All " & suite.TotalCount & " tests passed. Safe to deploy."
MsgBox msg, vbInformation, "Test Suite: All Green"
Else
msg = suite.FailCount & " of " & suite.TotalCount & _
" tests FAILED. Do not deploy until failures are resolved."
MsgBox msg, vbCritical, "Test Suite: Failures Detected"
End If
End Sub
' ── Report Rendering ────────────────────────────────────────────────
Private Sub RenderReport(suite As TestSuite)
Dim ws As Worksheet
' Get or create the report sheet
On Error Resume Next
Set ws = ThisWorkbook.Sheets(REPORT_SHEET_NAME)
On Error GoTo 0
If ws Is Nothing Then
Set ws = ThisWorkbook.Sheets.Add(After:=ThisWorkbook.Sheets(ThisWorkbook.Sheets.Count))
ws.Name = REPORT_SHEET_NAME
End If
ws.Cells.Clear
ws.Tab.Color = IIf(suite.FailCount = 0, COLOR_PASS, COLOR_FAIL)
' Write header row
With ws.Rows(1)
.Cells(1, COL_CATEGORY).Value = "Category"
.Cells(1, COL_TEST_NAME).Value = "Test Name"
.Cells(1, COL_STATUS).Value = "Status"
.Cells(1, COL_MESSAGE).Value = "Message"
.Interior.Color = COLOR_HEADER
.Font.Color = RGB(255, 255, 255)
.Font.Bold = True
End With
' Write summary row below header
ws.Cells(2, 1).Value = "SUMMARY"
ws.Cells(2, 2).Value = suite.TotalCount & " tests run"
ws.Cells(2, 3).Value = suite.PassCount & " passed / " & suite.FailCount & " failed"
ws.Cells(2, 4).Value = "Run at: " & Now()
ws.Rows(2).Font.Bold = True
' Write individual results
Dim row As Long
row = 3
Dim r As TestResult
For Each r In suite.Results
ws.Cells(row, COL_CATEGORY).Value = r.Category
ws.Cells(row, COL_TEST_NAME).Value = r.TestName
ws.Cells(row, COL_STATUS).Value = IIf(r.Passed, "PASS", "FAIL")
ws.Cells(row, COL_MESSAGE).Value = r.Message
If r.Passed Then
ws.Cells(row, COL_STATUS).Font.Color = COLOR_PASS
ws.Rows(row).Interior.Color = COLOR_PASS_BG
Else
ws.Cells(row, COL_STATUS).Font.Color = COLOR_FAIL
ws.Rows(row).Interior.Color = COLOR_FAIL_BG
ws.Cells(row, COL_STATUS).Font.Bold = True
End If
row = row + 1
Next r
' Auto-fit columns for readability
ws.Columns("A:D").AutoFit
' Activate the report sheet so developers see results immediately
ws.Activate
End Sub
Tip: The tab color change (
ws.Tab.Color) is a subtle but powerful UX touch. When you're scanning a workbook with many sheets, a red tab on "TestResults" is an immediate visual signal that something broke. You'll notice it even before you click the sheet.
Now let's write actual test subroutines. These follow a consistent pattern: set up controlled state, call the code under test, make assertions, then tear down. The setup-call-assert-teardown cycle is universal across all testing frameworks.
Pure functions — those that take inputs and return outputs without touching worksheets or global state — are the easiest to test. Let's say you have a financial helper module with this function:
' In module: FinancialHelpers
Option Explicit
Public Function AnnualizeReturn(monthlyReturn As Double, months As Long) As Double
' Compound annualization formula
AnnualizeReturn = ((1 + monthlyReturn) ^ (12 / months)) - 1
End Function
Public Function ApplyTieredDiscount(basePrice As Double, quantity As Long) As Double
Select Case quantity
Case 1 To 49: ApplyTieredDiscount = basePrice
Case 50 To 199: ApplyTieredDiscount = basePrice * 0.9
Case 200 To 499: ApplyTieredDiscount = basePrice * 0.82
Case Is >= 500: ApplyTieredDiscount = basePrice * 0.75
Case Else: ApplyTieredDiscount = 0
End Select
End Function
Here's the test subroutine for these:
' In module: Tests_FinancialHelpers
Option Explicit
Public Sub Test_RevenueCalculations(suite As TestSuite)
' ── AnnualizeReturn ──────────────────────────────────────────────
suite.BeginTest "AnnualizeReturn: standard 12-month", "Financial"
suite.AssertNearlyEqual 0.12, AnnualizeReturn(0.12, 12), 0.0001
' A 1% monthly return should annualize to ~12.68%
suite.BeginTest "AnnualizeReturn: 1% monthly compound", "Financial"
suite.AssertNearlyEqual 0.126825, AnnualizeReturn(0.01, 12), 0.0001
suite.BeginTest "AnnualizeReturn: 6-month annualization", "Financial"
suite.AssertNearlyEqual 0.040604, AnnualizeReturn(0.02, 6), 0.0001
' ── ApplyTieredDiscount ──────────────────────────────────────────
suite.BeginTest "Discount: single unit, no discount", "Pricing"
suite.AssertEqual 100#, ApplyTieredDiscount(100#, 1)
suite.BeginTest "Discount: boundary at 50 units (tier 2)", "Pricing"
suite.AssertNearlyEqual 90#, ApplyTieredDiscount(100#, 50), 0.01
suite.BeginTest "Discount: boundary at 49 units (tier 1)", "Pricing"
suite.AssertEqual 100#, ApplyTieredDiscount(100#, 49)
suite.BeginTest "Discount: 500+ units max discount", "Pricing"
suite.AssertNearlyEqual 75#, ApplyTieredDiscount(100#, 500), 0.01
suite.BeginTest "Discount: large quantity still tier 4", "Pricing"
suite.AssertNearlyEqual 1500#, ApplyTieredDiscount(2#, 1000), 0.01
End Sub
Notice we're testing boundary conditions specifically: unit 49 vs 50, unit 499 vs 500. These are the exact places where Select Case and If...ElseIf logic most commonly has off-by-one errors. Always test the edges of every branch, not just the midpoints.
Data cleaning is where VBA macros do a lot of heavy lifting. Let's say you have a function that standardizes phone numbers:
' In module: DataCleaningHelpers
Option Explicit
Public Function StandardizePhone(raw As String) As String
' Strip everything except digits
Dim result As String
Dim i As Integer
For i = 1 To Len(raw)
If Mid(raw, i, 1) Like "[0-9]" Then
result = result & Mid(raw, i, 1)
End If
Next i
' Format as (XXX) XXX-XXXX if 10 digits
If Len(result) = 10 Then
StandardizePhone = "(" & Left(result, 3) & ") " & _
Mid(result, 4, 3) & "-" & Right(result, 4)
ElseIf Len(result) = 11 And Left(result, 1) = "1" Then
' Handle leading country code 1
result = Mid(result, 2)
StandardizePhone = "(" & Left(result, 3) & ") " & _
Mid(result, 4, 3) & "-" & Right(result, 4)
Else
StandardizePhone = "INVALID: " & raw
End If
End Function
The tests for this need to cover the messy real-world inputs your data will actually contain:
Public Sub Test_DataCleaningFunctions(suite As TestSuite)
' Standard formats
suite.BeginTest "Phone: clean 10-digit", "DataCleaning"
suite.AssertEqual "(312) 555-0199", StandardizePhone("3125550199")
suite.BeginTest "Phone: dashes format", "DataCleaning"
suite.AssertEqual "(312) 555-0199", StandardizePhone("312-555-0199")
suite.BeginTest "Phone: parentheses format", "DataCleaning"
suite.AssertEqual "(312) 555-0199", StandardizePhone("(312) 555-0199")
suite.BeginTest "Phone: dots format", "DataCleaning"
suite.AssertEqual "(312) 555-0199", StandardizePhone("312.555.0199")
' Country code handling
suite.BeginTest "Phone: with US country code +1", "DataCleaning"
suite.AssertEqual "(312) 555-0199", StandardizePhone("+13125550199")
suite.BeginTest "Phone: with 1 prefix no plus", "DataCleaning"
suite.AssertEqual "(312) 555-0199", StandardizePhone("13125550199")
' Invalid inputs
suite.BeginTest "Phone: 9-digit is invalid", "DataCleaning"
suite.AssertTrue Left(StandardizePhone("312555019"), 8) = "INVALID:"
suite.BeginTest "Phone: empty string is invalid", "DataCleaning"
suite.AssertTrue Left(StandardizePhone(""), 8) = "INVALID:"
suite.BeginTest "Phone: letters only is invalid", "DataCleaning"
suite.AssertTrue Left(StandardizePhone("CALL ME"), 8) = "INVALID:"
End Sub
Note: The
Left(result, 8) = "INVALID:"pattern is a deliberate choice. We're not asserting the exact error message — just that the function signals invalidity correctly. This makes the test more resilient to future changes in error message wording.
The trickiest tests to write are those that validate that a macro correctly modified a worksheet. The pattern is: use a dedicated test worksheet (not production data), run the macro, assert cell values, then clean up.
First, establish a convention: your workbook has a sheet named _TestData (the underscore prefix signals "infrastructure, not business data"). This sheet contains controlled input data that your macros can operate on during tests.
' In module: Tests_WorksheetValidation
Option Explicit
Public Sub Test_WorksheetOutputValidation(suite As TestSuite)
Dim ws As Worksheet
Dim wsOut As Worksheet
' Get our test data sheet — if it doesn't exist, skip gracefully
On Error Resume Next
Set ws = ThisWorkbook.Sheets("_TestData")
On Error GoTo 0
If ws Is Nothing Then
suite.BeginTest "WorksheetTests: _TestData sheet exists", "Infrastructure"
suite.AssertFalse True ' Force-fail with clear indication of missing dependency
Exit Sub
End If
' ── Test: SummariseRegionalSales macro ───────────────────────────
' Set up controlled input data
ws.UsedRange.Clear
ws.Range("A1").Value = "Region"
ws.Range("B1").Value = "Product"
ws.Range("C1").Value = "Revenue"
ws.Range("A2:A6").Value = Application.Transpose(Array("North", "South", "North", "East", "South"))
ws.Range("B2:B6").Value = Application.Transpose(Array("Widget", "Widget", "Gadget", "Widget", "Gadget"))
ws.Range("C2:C6").Value = Application.Transpose(Array(10000, 15000, 8000, 12000, 9000))
' Run the macro under test — it should produce a summary on the Output sheet
Call SummariseRegionalSales(ws, "North")
' Now assert the results
On Error Resume Next
Set wsOut = ThisWorkbook.Sheets("_Output")
On Error GoTo 0
suite.BeginTest "SummariseRegionalSales: output sheet created", "WorksheetOutput"
suite.AssertFalse wsOut Is Nothing
If Not wsOut Is Nothing Then
suite.BeginTest "SummariseRegionalSales: North total revenue", "WorksheetOutput"
suite.AssertCellEquals wsOut, "B2", 18000 ' 10000 + 8000
suite.BeginTest "SummariseRegionalSales: North row count", "WorksheetOutput"
suite.AssertRowCount wsOut, "A2:A3", 2
suite.BeginTest "SummariseRegionalSales: header present", "WorksheetOutput"
suite.AssertCellEquals wsOut, "A1", "Region"
' Clean up output sheet after test
Application.DisplayAlerts = False
wsOut.Delete
Application.DisplayAlerts = True
End If
End Sub
This pattern — create controlled state, run, assert, clean up — is the foundation of worksheet testing. The cleanup step is critical: leave no side effects that could corrupt subsequent tests or, worse, production data.
Warning: Never run tests against production data worksheets. Always create a dedicated
_TestDatasheet with synthetic data you control completely. If your macros modify data in-place, the test must restore the original state before finishing. Use a cleanup pattern at the end of every worksheet test, even if you're usingOn Error GoToto ensure it runs even when tests fail.
Date calculations are notorious in VBA because of locale differences, type coercion surprises, and the fact that Date and DateValue behave differently depending on system settings.
' In module: DateHelpers
Option Explicit
Public Function GetFiscalQuarter(d As Date, fiscalYearStartMonth As Integer) As Integer
Dim monthOffset As Integer
monthOffset = ((Month(d) - fiscalYearStartMonth + 12) Mod 12)
GetFiscalQuarter = (monthOffset \ 3) + 1
End Function
Public Function WorkingDaysBetween(startDate As Date, endDate As Date) As Long
Dim d As Date
Dim count As Long
count = 0
For d = startDate To endDate
If Weekday(d, vbMonday) <= 5 Then count = count + 1
Next d
WorkingDaysBetween = count
End Function
Public Sub Test_DateHelpers(suite As TestSuite)
' GetFiscalQuarter — fiscal year starting April (UK/many corporate FYs)
suite.BeginTest "FiscalQ: April = Q1 for Apr-start FY", "DateLogic"
suite.AssertEqual 1, GetFiscalQuarter(DateSerial(2024, 4, 1), 4)
suite.BeginTest "FiscalQ: March = Q4 for Apr-start FY", "DateLogic"
suite.AssertEqual 4, GetFiscalQuarter(DateSerial(2024, 3, 31), 4)
suite.BeginTest "FiscalQ: January = Q1 for Jan-start FY", "DateLogic"
suite.AssertEqual 1, GetFiscalQuarter(DateSerial(2024, 1, 15), 1)
suite.BeginTest "FiscalQ: December = Q4 for Jan-start FY", "DateLogic"
suite.AssertEqual 4, GetFiscalQuarter(DateSerial(2024, 12, 31), 1)
' WorkingDaysBetween
suite.BeginTest "WorkingDays: Mon to Fri = 5 days", "DateLogic"
suite.AssertEqual 5, WorkingDaysBetween(DateSerial(2024, 9, 2), DateSerial(2024, 9, 6))
' Sep 2 2024 is a Monday, Sep 6 is Friday — 5 working days inclusive
suite.BeginTest "WorkingDays: across a weekend = 5 days", "DateLogic"
suite.AssertEqual 5, WorkingDaysBetween(DateSerial(2024, 9, 2), DateSerial(2024, 9, 8))
' Mon to Sun should still be 5 working days
suite.BeginTest "WorkingDays: same day = 1", "DateLogic"
suite.AssertEqual 1, WorkingDaysBetween(DateSerial(2024, 9, 2), DateSerial(2024, 9, 2))
End Sub
Notice we use DateSerial rather than date literals like #9/2/2024#. This is intentional: date literals in VBA are interpreted by the VBE at design time using the system locale, which means a workbook developed on a US machine can have subtly different behavior when opened on a UK machine. DateSerial(year, month, day) is always unambiguous.
Here's where the real return on investment becomes clear. If you've been building VBA macros in the conventional monolithic style, you'll find many of them cannot be tested at all without refactoring. This is a feature of the testing discipline: it forces architectural improvements.
The most common untestable pattern is the "god macro" — a single Sub that reads from a sheet, processes, and writes back, all in one block with no extractable pieces:
' UNTESTABLE — monolithic, tightly coupled
Sub ProcessMonthlyReport()
Dim ws As Worksheet
Set ws = ThisWorkbook.Sheets("RawData")
Dim total As Double
Dim i As Long
For i = 2 To ws.Cells(Rows.Count, 1).End(xlUp).Row
If ws.Cells(i, 3).Value > 0 Then
total = total + ws.Cells(i, 3).Value * _
IIf(ws.Cells(i, 4).Value = "Premium", 1.15, 1.0)
End If
Next i
ThisWorkbook.Sheets("Summary").Range("B5").Value = total
End Sub
To test this, you'd have to have the exact worksheets named exactly right, with data in exactly the right cells. Any deviation breaks it. Instead, extract the business logic into a pure function:
' TESTABLE — business logic separated from I/O
Public Function CalculateWeightedRevenue(amounts() As Double, _
tiers() As String) As Double
Dim total As Double
Dim i As Long
For i = LBound(amounts) To UBound(amounts)
If amounts(i) > 0 Then
total = total + amounts(i) * IIf(tiers(i) = "Premium", 1.15, 1.0)
End If
Next i
CalculateWeightedRevenue = total
End Function
' The macro now just coordinates I/O and calls the pure function
Sub ProcessMonthlyReport()
Dim ws As Worksheet
Set ws = ThisWorkbook.Sheets("RawData")
Dim lastRow As Long
lastRow = ws.Cells(ws.Rows.Count, 1).End(xlUp).Row
Dim amounts() As Double
Dim tiers() As String
ReDim amounts(2 To lastRow)
ReDim tiers(2 To lastRow)
Dim i As Long
For i = 2 To lastRow
amounts(i) = ws.Cells(i, 3).Value
tiers(i) = ws.Cells(i, 4).Value
Next i
ThisWorkbook.Sheets("Summary").Range("B5").Value = _
CalculateWeightedRevenue(amounts, tiers)
End Sub
Now CalculateWeightedRevenue is a pure function. You can pass it any array of test data and assert on its return value without touching any worksheet. This architectural separation — I/O at the edges, logic in the middle — is the single most important refactoring you can do to enable testing.
If you're building larger macro systems like those described in Building an Automated Reporting System with VBA, this separation becomes even more critical, because the more macros interact with each other, the harder it is to debug when something breaks without tests.
Tests only add value if they actually run. Here are three integration patterns that work well in different scenarios.
Add a button to a custom ribbon tab (or just a button on a worksheet) that runs RunAllTests. Make it part of your checklist: before you mark a macro task done, you click "Run Tests." If the tab goes red, you don't deploy.
If you've already built custom ribbon tools following the approach in Building Excel Add-Ins with VBA: Package and Deploy Custom Tools Across Your Organization, you can add a "Run Tests" button to your add-in's ribbon group.
For workbooks used in production automation pipelines — like those described in Building a Self-Updating Excel Report with Power Query, VBA, and Scheduled Refresh — you can run a lightweight smoke test every time the workbook opens:
' In ThisWorkbook
Private Sub Workbook_Open()
' Run a fast subset of tests to verify core integrity
Call RunSmokeTests
End Sub
' In TestRunner module
Public Sub RunSmokeTests()
' Only run the fastest, most critical tests on open
' Full suite runs manually pre-deployment
Dim suite As New TestSuite
Call Test_DateHelpers(suite)
Call Test_RevenueCalculations(suite)
If suite.FailCount > 0 Then
MsgBox "INTEGRITY CHECK FAILED: " & suite.FailCount & _
" critical tests failed. This workbook may be corrupted." & _
vbNewLine & "Do not use until failures are investigated.", _
vbCritical, "Workbook Integrity Alert"
End If
End Sub
Tip: Keep the
Workbook_Opensmoke test fast — under 2 seconds. If it takes longer, users will disable it. Reserve the full test suite (which may take 10-30 seconds for large suites) for the manual pre-deployment run. Categorize your tests as "smoke" vs. "full" and select accordingly.
If your VBA macro is triggered by another system — a scheduled task, a Power Automate flow, or an external script — you can structure the entry point to run tests before doing any real work:
Public Sub AutomatedEntryPoint()
' Run tests first; abort if any fail
Dim suite As New TestSuite
Call Test_DataCleaningFunctions(suite)
Call Test_RevenueCalculations(suite)
If suite.FailCount > 0 Then
Call LogError("Pre-run tests failed: " & suite.FailCount & " failures. Aborting.")
Exit Sub
End If
' Safe to proceed with actual work
Call ProcessMonthlyReport
Call SummariseRegionalSales(ThisWorkbook.Sheets("RawData"), "All")
Call ExportToPDF
End Sub
This turns your test suite into a gate: the real code doesn't run at all if the environment is in a known-bad state. This is especially valuable when your macros touch external data sources, databases, or shared network files.
Now you'll build a complete mini testing framework for a realistic scenario. This exercise will take 45-60 minutes and produces a working, deployable system.
Scenario: Your company has a workbook with a macro that processes a monthly sales extract. It performs three operations: (1) strips whitespace from product codes, (2) calculates a commission based on deal size tiers, and (3) outputs a summary by territory.
Step 1: Build the production code. Create a standard module named SalesProcessing and add these three functions:
Option Explicit
Public Function CleanProductCode(raw As String) As String
CleanProductCode = Trim(UCase(raw))
End Function
Public Function CalculateCommission(dealSize As Double, repLevel As String) As Double
Dim baseRate As Double
Select Case True
Case dealSize < 10000: baseRate = 0.05
Case dealSize < 50000: baseRate = 0.07
Case dealSize < 100000: baseRate = 0.09
Case Else: baseRate = 0.11
End Select
Dim multiplier As Double
Select Case LCase(repLevel)
Case "junior": multiplier = 0.8
Case "senior": multiplier = 1.0
Case "director": multiplier = 1.2
Case Else: multiplier = 1.0
End Select
CalculateCommission = dealSize * baseRate * multiplier
End Function
Public Function TerritoryTotal(amounts As Variant, territories As Variant, _
targetTerritory As String) As Double
Dim total As Double
Dim i As Long
For i = LBound(amounts) To UBound(amounts)
If LCase(territories(i)) = LCase(targetTerritory) Then
total = total + amounts(i)
End If
Next i
TerritoryTotal = total
End Function
Step 2: Add the TestResult and TestSuite class modules from the code earlier in this lesson.
Step 3: Add the TestRunner module and modify RunAllTests to call a new sub named Test_SalesProcessing.
Step 4: Write the test suite. Create a module named Tests_SalesProcessing:
Option Explicit
Public Sub Test_SalesProcessing(suite As TestSuite)
' ── CleanProductCode ────────────────────────────────────────────
suite.BeginTest "CleanCode: strips leading spaces", "DataCleaning"
suite.AssertEqual "WIDGET-A", CleanProductCode(" widget-a")
suite.BeginTest "CleanCode: strips trailing spaces", "DataCleaning"
suite.AssertEqual "GADGET-X", CleanProductCode("gadget-x ")
suite.BeginTest "CleanCode: normalizes to uppercase", "DataCleaning"
suite.AssertEqual "SKU-123", CleanProductCode("sku-123")
suite.BeginTest "CleanCode: already clean passthrough", "DataCleaning"
suite.AssertEqual "PRODUCT", CleanProductCode("PRODUCT")
' ── CalculateCommission ─────────────────────────────────────────
suite.BeginTest "Commission: junior rep, small deal", "Commission"
suite.AssertNearlyEqual 400#, CalculateCommission(10000, "Junior"), 0.01
' Wait — 10000 * 0.05 * 0.8 = 400, but our Select Case uses <10000
' so 10000 falls into the 0.07 tier! This is the kind of off-by-one
' your tests will catch.
suite.BeginTest "Commission: senior rep, mid deal", "Commission"
suite.AssertNearlyEqual 3500#, CalculateCommission(50000, "Senior"), 0.01
suite.BeginTest "Commission: director multiplier on large deal", "Commission"
suite.AssertNearlyEqual 13200#, CalculateCommission(100000, "Director"), 0.01
suite.BeginTest "Commission: unknown rep level uses 1.0 multiplier", "Commission"
suite.AssertNearlyEqual 550#, CalculateCommission(10000, "Contractor"), 0.01
' ── TerritoryTotal ──────────────────────────────────────────────
Dim amounts As Variant
Dim territories As Variant
amounts = Array(10000, 25000, 8000, 15000, 30000)
territories = Array("North", "South", "North", "East", "South")
suite.BeginTest "TerritoryTotal: North sums correctly", "Aggregation"
suite.AssertNearlyEqual 18000#, TerritoryTotal(amounts, territories, "North"), 0.01
suite.BeginTest "TerritoryTotal: South sums correctly", "Aggregation"
suite.AssertNearlyEqual 55000#, TerritoryTotal(amounts, territories, "South"), 0.01
suite.BeginTest "TerritoryTotal: case-insensitive match", "Aggregation"
suite.AssertNearlyEqual 18000#, TerritoryTotal(amounts, territories, "NORTH"), 0.01
suite.BeginTest "TerritoryTotal: missing territory returns 0", "Aggregation"
suite.AssertNearlyEqual 0#, TerritoryTotal(amounts, territories, "West"), 0.01
End Sub
Step 5: Run your tests. Press Alt+F8, select RunAllTests, and run it. Study the TestResults sheet. You'll notice the commission test for the junior rep reveals a boundary condition issue: deal size 10000 actually hits the <50000 tier (0.07 rate) rather than the <10000 tier. The correct assertion should be 700 not 400. Your test caught the misunderstanding in your mental model of the code. Decide: is the code right and the test wrong, or vice versa? Either way, you now know.
If you define the TestSuite as a module-level global variable and reset it between runs without reinitializing, you'll accumulate stale results. Always instantiate TestSuite fresh at the start of RunAllTests with Dim suite As New TestSuite. Never reuse a suite object across separate test runs.
This happens when your assertion is too weak. If you write suite.AssertTrue result >= 0 and the function returns 0 when it should return 9000, your test passes silently. Always assert the specific expected value, not just a direction or range. Weak assertions give you false confidence — arguably worse than no tests at all.
If test A creates a worksheet that test B then tries to create again, you'll get an error in test B that looks like a code failure but is actually an environmental failure. This is "test pollution." Fix it by ensuring each test cleans up after itself, and structure tests to be completely independent. Never rely on tests running in a particular order.
When testing code that uses On Error Resume Next internally, errors get swallowed. If your production code suppresses errors too aggressively (a common pattern covered in Error Handling and Debugging VBA Code Like a Pro), your tests may report PASS because no error was raised — when in fact the function produced a wrong result silently. Always assert the actual output value, not just the absence of an error.
Some VBA operations — creating PivotTables, formatting charts, running Solver — are very hard to unit test because they create heavy Excel objects. For this kind of code, write integration tests instead: scripts that set up a full worksheet environment and verify the end state. These run slower but are still vastly better than no tests. See the Automating Excel PivotTables with VBA article for patterns that make PivotTable code more testable by separating configuration from creation.
If your test suite grows to hundreds of tests and starts taking 2+ minutes to run, developers will stop running it. Profile which tests are slow — usually anything with loops over large datasets or worksheet I/O. Refactor those tests to use smaller synthetic datasets. Real-world scale testing belongs in a separate performance test suite, not your unit test suite. For workbook-level performance concerns, Excel Performance Optimization has techniques relevant to keeping your test harness fast.
Key insight: A slow test suite is an abandoned test suite. Keep your unit test run under 10 seconds. If you have tests that inherently take longer (worksheet manipulation, external API calls, database queries), tag them separately and run them only pre-deployment, not on every edit cycle.
Once your basic framework is working, a few additions make it significantly more powerful.
When a subroutine under test raises an unhandled error, it will currently crash the test runner. Protect against this by wrapping each test sub call in an error handler:
Private Sub SafeRunTest(suite As TestSuite, testName As String)
' Use this pattern to wrap test subs that might crash
On Error GoTo TestCrashed
Exit Sub
TestCrashed:
suite.BeginTest testName & " [CRASHED]", "Errors"
suite.AssertFalse True ' Force-fail
' Err.Description gives the crash message — log it
End Sub
A cleaner approach is to use a dispatcher pattern with CallByName to invoke test subs dynamically, catching errors at the runner level. This requires more infrastructure but produces a much more robust framework.
When you need to test the same function with many different inputs, don't copy-paste BeginTest/Assert pairs. Build a data-driven helper:
Private Sub RunCommissionCases(suite As TestSuite)
Dim cases(1 To 4, 1 To 4) As Variant ' dealSize, level, expected, name
cases(1, 1) = 5000: cases(1, 2) = "Senior": cases(1, 3) = 250: cases(1, 4) = "5k senior"
cases(2, 1) = 25000: cases(2, 2) = "Junior": cases(2, 3) = 1400: cases(2, 4) = "25k junior"
cases(3, 1) = 75000: cases(3, 2) = "Director": cases(3, 3) = 9720: cases(3, 4) = "75k director"
cases(4, 1) = 200000: cases(4, 2) = "Senior": cases(4, 3) = 22000: cases(4, 4) = "200k senior"
Dim i As Integer
For i = 1 To 4
suite.BeginTest "Commission: " & cases(i, 4), "Commission"
suite.AssertNearlyEqual CDbl(cases(i, 3)), _
CalculateCommission(CDbl(cases(i, 1)), CStr(cases(i, 2))), 0.01
Next i
End Sub
This pattern scales beautifully: adding a new test case is one line in the array, not four lines of boilerplate.
Add a module-level dictionary (using Scripting.Dictionary) to track which production functions were exercised during the test run. This gives you a rough coverage metric — not as sophisticated as line-level coverage in professional IDEs, but better than nothing.
You've built a complete VBA testing framework from scratch: a TestResult class that records outcomes, a TestSuite class with a rich assertion library covering equality, numeric tolerance, types, empties, and worksheet validation, and a TestRunner that produces color-coded reports and integrates into your deployment workflow.
More importantly, you've adopted the mindset shift that makes professional VBA development sustainable: test-first thinking. You write code knowing you'll need to test it, which means you write it in smaller, purer, more focused pieces. That design discipline produces better code whether or not you actually write the tests.
Here's how to build on what you've learned:
AssertValidSKU, AssertCurrencyRounded, or AssertDateIsWeekdayThe gap between "I think this works" and "I can prove this works to 47 assertions" is the gap between VBA as a personal tool and VBA as professional-grade infrastructure. You now have the tools to close it.