Test win32com and xlwings Code Without Excel
Code that automates Excel itself — refreshing connections, running macros, printing, reading values Excel has calculated — is the hardest Python Excel code to test. It needs Windows, an installed and licensed Excel, and a desktop session; CI runners, colleagues' Macs and containers have none of these. So it often goes untested, and its bugs show up as a hung EXCEL.EXE on a server, a workbook saved over by mistake, or a macro run against the wrong file. Most of the logic in such code, though, is not Excel's: which files to open, which sheet and range to read, what to do with the values, when to save and how to clean up when something fails. That logic can be tested anywhere, if the Excel calls are kept at the edge and replaced in tests with small fakes. This page restructures a win32com refresh job and an xlwings report so they can be tested on Linux, writes the fakes, tests the clean-up path, and keeps real-Excel tests for the machines that have it. It belongs to Testing and Packaging Excel Automation Scripts.
Prerequisites
pip install pytest pandas
# pywin32 and xlwings are only needed where Excel is installed
The tests on this page were run on Linux, where neither Excel nor pywin32 exists — exactly the situation of a CI runner. xlwings installs on Linux but cannot start Excel there, so the same approach applies to it.
Step 1 — Keep the Excel calls in one thin function
The job opens a workbook, refreshes its data connections, waits for the refresh, reads a total from a named cell, saves and closes — and must quit Excel whatever happens. Write it so that the Excel application is passed in rather than created inside:
import logging
log = logging.getLogger(__name__)
def refresh_and_read(app, path, total_name="GrandTotal"):
"""Refresh all connections in the workbook at path and return the value of a named cell."""
wb = app.Workbooks.Open(str(path), UpdateLinks=0, ReadOnly=False)
try:
wb.RefreshAll()
app.CalculateUntilAsyncQueriesDone()
total = wb.Names(total_name).RefersToRange.Value
if total is None:
raise ValueError(f"{total_name} is empty after refresh")
wb.Save()
return float(total)
finally:
wb.Close(SaveChanges=False)
def run(paths, make_app):
app = make_app()
app.DisplayAlerts = False
try:
return {str(p): refresh_and_read(app, p) for p in paths}
finally:
app.Quit()
if __name__ == "__main__": # the only place that knows about win32com
import win32com.client
print(run(["C:/reports/sales.xlsx"], lambda: win32com.client.DispatchEx("Excel.Application")))
run receives a make_app function instead of calling win32com.client.DispatchEx itself; only the __main__ block, which tests never execute, imports win32com. The try … finally blocks are the important part: whatever goes wrong — a missing name, a failed refresh — every opened workbook is closed without saving over it, and Excel is told to quit. Those are exactly the behaviours worth testing, because getting them wrong leaves invisible Excel processes running on a server until someone kills them. The COM calls themselves are covered in Refresh Excel Data Connections and Recalculate with win32com, and what happens when clean-up is skipped in Close Excel Cleanly and Avoid Orphan COM Processes.
Step 2 — Fakes that record what happened
A fake is a small class with the same attribute names as the COM objects the code uses, which records calls instead of performing them. It needs only the parts the code touches:
class FakeRange:
def __init__(self, value):
self.Value = value
class FakeName:
def __init__(self, value):
self.RefersToRange = FakeRange(value)
class FakeWorkbook:
def __init__(self, app, path, names, fail_refresh=False):
self.app, self.path, self._names, self.fail_refresh = app, path, names, fail_refresh
self.saved = self.closed = False
self.close_args = None
def RefreshAll(self):
self.app.calls.append(("RefreshAll", self.path))
if self.fail_refresh:
raise RuntimeError("(-2147352567, 'Exception occurred.', 'The connection failed')")
def Names(self, name):
if name not in self._names:
raise KeyError(name)
return FakeName(self._names[name])
def Save(self):
self.saved = True
def Close(self, SaveChanges):
self.closed, self.close_args = True, {"SaveChanges": SaveChanges}
class FakeWorkbooks:
def __init__(self, app):
self.app = app
def Open(self, path, **kwargs):
wb = FakeWorkbook(self.app, path, **self.app.books[path])
self.app.opened.append(wb)
return wb
class FakeExcel:
def __init__(self, books):
self.books, self.opened, self.calls = books, [], []
self.Workbooks = FakeWorkbooks(self)
self.DisplayAlerts = True
self.quit_called = False
def CalculateUntilAsyncQueriesDone(self):
self.calls.append(("CalculateUntilAsyncQueriesDone",))
def Quit(self):
self.quit_called = True
Plain classes are clearer than unittest.mock.MagicMock here. A MagicMock accepts any attribute and call, so a typo such as wb.RefreshALL() passes silently; a fake raises AttributeError, just as the real object would fail. The fake's books are configured per test: which named values exist and whether refreshing should fail with a COM-style error.
Step 3 — Test the happy path and the clean-up path
import pytest
from jobs.refresh import run
from tests.fakes import FakeExcel
def test_refreshes_saves_closes_and_quits():
excel = FakeExcel({"a.xlsx": {"names": {"GrandTotal": 1250.5}}, "b.xlsx": {"names": {"GrandTotal": 80}}})
result = run(["a.xlsx", "b.xlsx"], make_app=lambda: excel)
assert result == {"a.xlsx": 1250.5, "b.xlsx": 80.0}
assert [c[0] for c in excel.calls] == ["RefreshAll", "CalculateUntilAsyncQueriesDone"] * 2
assert all(wb.saved and wb.closed for wb in excel.opened)
assert excel.DisplayAlerts is False and excel.quit_called
def test_failed_refresh_closes_without_saving_and_quits():
excel = FakeExcel({"a.xlsx": {"names": {"GrandTotal": 1}, "fail_refresh": True}})
with pytest.raises(RuntimeError, match="connection failed"):
run(["a.xlsx"], make_app=lambda: excel)
wb = excel.opened[0]
assert wb.closed and wb.close_args == {"SaveChanges": False} and not wb.saved
assert excel.quit_called
def test_empty_total_is_an_error_not_a_zero():
excel = FakeExcel({"a.xlsx": {"names": {"GrandTotal": None}}})
with pytest.raises(ValueError, match="GrandTotal is empty"):
run(["a.xlsx"], make_app=lambda: excel)
assert excel.opened[0].closed and excel.quit_called
import subprocess, sys
def pytest_run(*args):
out = subprocess.run([sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", *args], capture_output=True, text=True).stdout
print("\n".join(l for l in out.splitlines() if "passed" in l or "failed" in l or "skipped" in l or l.startswith("FAILED")))
pytest_run("tests/test_refresh.py")
All three tests pass in well under a second, on a machine without Excel. The second test is the one that pays for the effort: a refresh failure — simulated with the kind of message a COM error carries — still closes the workbook without saving and still quits Excel. The third makes sure an empty named cell is reported as an error rather than quietly becoming a total of zero. These are the behaviours that, untested, produce hung processes and wrong numbers in production.
Step 4 — xlwings code and older code that imports win32com
xlwings code is tested the same way. Pass the book in, and fake the parts of the xlwings API the code uses — here sheet.range(...).options(...).value:
import pandas as pd
def write_summary(book, df):
sheet = book.sheets["Summary"]
sheet.range("A1").options(index=False).value = df.groupby("Region", as_index=False)["Amount"].sum()
sheet.range("E1").value = "Total"
sheet.range("F1").value = float(df["Amount"].sum())
import pandas as pd
from jobs.xw_report import write_summary
class FakeXwRange:
def __init__(self, sheet, address):
self.sheet, self.address, self.opts = sheet, address, {}
def options(self, **opts):
self.opts = opts
return self
@property
def value(self):
return self.sheet.cells.get(self.address)
@value.setter
def value(self, v):
self.sheet.cells[self.address] = (v, self.opts)
class FakeXwSheet:
def __init__(self):
self.cells = {}
def range(self, address):
return FakeXwRange(self, address)
class FakeBook:
def __init__(self):
self.sheets = {"Summary": FakeXwSheet()}
def test_summary_written_without_index():
book = FakeBook()
df = pd.DataFrame({"Region": ["N", "S", "N"], "Amount": [10.0, 5.0, 2.5]})
write_summary(book, df)
table, opts = book.sheets["Summary"].cells["A1"]
assert opts == {"index": False}
assert table.to_dict("list") == {"Region": ["N", "S"], "Amount": [12.5, 5.0]}
assert book.sheets["Summary"].cells["F1"][0] == 17.5
For older code that does import win32com.client at the top of the module, inject a stand-in module before importing it, in a fixture or conftest.py: sys.modules["win32com"] = types.ModuleType("win32com") plus a win32com.client module whose Dispatch returns your fake. It works, but it is a sign the code would benefit from Step 1's restructuring. Tests that really need Excel — checking that a macro still exists, or that a refresh against a test database succeeds — go in a separate file guarded with pytest.importorskip("win32com.client") or a skipif(sys.platform != "win32") marker, and run on a Windows machine with Excel:
import sys
import pytest
pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="needs Windows with Excel installed")
def test_real_refresh(tmp_path):
win32 = pytest.importorskip("win32com.client")
from jobs.refresh import run
result = run(["C:/ci/fixtures/sales.xlsx"], lambda: win32.DispatchEx("Excel.Application"))
assert result["C:/ci/fixtures/sales.xlsx"] > 0
pytest_run("tests")
Four tests pass and the real-Excel test is skipped on Linux (run with -rs to see the skip reason) — so the suite runs everywhere, and the Windows machine that has Excel runs the one extra test. Running Excel jobs on a server in the first place has its own pitfalls, covered in Run Excel COM Automation from Task Scheduler Without a Logged-In User.
Common pitfalls
- Creating Excel inside the logic. Pass the application in so tests can substitute it.
- MagicMock everywhere. It hides typos; small fakes fail like the real thing.
- Only testing success. Test that failures still close workbooks and quit Excel.
- Imports that need Windows. Keep
win32comimports at the entry point. - Real-Excel tests that fail on CI. Skip them by platform and run them where Excel exists.
Conclusion
To test Excel-driving code without Excel, keep the COM or xlwings calls in thin functions that receive the application or book as an argument, and import win32com only at the entry point. Replace the real objects in tests with small recording fakes that define only what the code uses, simulate failures, and assert that workbooks are closed without saving and Excel is quit even when something goes wrong. Keep the few tests that need real Excel in a separate file skipped on other platforms.
Frequently asked questions
How do I unit test win32com code on Linux? Pass the Excel application into your functions instead of creating it with Dispatch, and in tests pass a fake object with the same methods and properties that records what was called.
Should I use MagicMock for COM objects? Small hand-written fakes are safer; MagicMock accepts any attribute, so misspelled COM method names pass silently.
How do I test that Excel is closed after an error? Make the fake raise an error during a step such as RefreshAll, then assert that the workbook's Close was called with SaveChanges False and that Quit was called.
How do I run real Excel tests only on Windows? Put them in a separate module marked with skipif for non-Windows platforms, and use pytest.importorskip for win32com.client.
Related
- Up one level: Testing and Packaging Excel Automation Scripts — reliable, shippable code.
- Test Excel Output with pytest — testing generated files.
- Run an Excel Macro from Python with win32com — code worth testing this way.
- Test Excel Code Against Several Library Versions with nox — running the suite in many environments.
More on com & pywin32
- GuideAutomating Excel with COM and pywin32
- How-toAutomate Excel on macOS with Python
- How-toClose Excel Cleanly and Avoid Orphan COM Processes
- FixHandle COM Errors and Excel Dialog Prompts in Python
- How-toPrint an Excel Workbook from Python with win32com
- How-toRead and Write Cell Ranges with win32com