Metadata-Version: 2.4
Name: pbi-unit-test
Version: 0.1.1
Summary: Unit testing framework for Power BI measures
Author: Angelo
License-Expression: MIT
Project-URL: Homepage, https://github.com/acanepas/powerbi-unit-test
Project-URL: Repository, https://github.com/acanepas/powerbi-unit-test
Keywords: powerbi,testing,dax,measures
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Operating System :: Microsoft :: Windows
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pandas
Requires-Dist: pythonnet
Requires-Dist: pyadomd
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Dynamic: license-file

# Power BI Unit Tests

Run DAX unit tests against a locally open Power BI Desktop model — no Power BI Service required.

## How it works

The tool connects to the Analysis Services instance that Power BI Desktop runs locally (`msmdsrv.exe`) via ADOMD.NET. It reads a test-case table from your model, evaluates each measure with its filters, and compares the result against the expected value.

## Prerequisites

- Windows (Power BI Desktop is Windows-only)
- Power BI Desktop open with a model loaded
- Python 3.9+
- .NET Framework 4.7.2 (usually already present on Windows)

## Installation

# 

### From pip

```bash
pip install pbi-unit-test
```

The ADOMD.NET client library is bundled with the package, so no additional setup is required.

### Custom ADOMD path (optional)

If you need to use a different version of the ADOMD client library, set the `ADOMD_PATH` environment variable:

```bash
set ADOMD_PATH=C:\path\to\your\adomd\net472
```

## Test table schema

Create a calculated table (or a regular table) in your Power BI model with these columns:

| Column             | Type    | Description                                                              |
| ------------------ | ------- | ------------------------------------------------------------------------ |
| `[Measure]`        | Text    | Name of the measure to test                                              |
| `[Filters]`        | Text    | DAX filter context passed to `CALCULATE`, e.g. `Store[Country] = "US"`   |
| `[WithRounding?]`  | Boolean | If `TRUE`, rounds the actual result to 0 decimal places before comparing |
| `[Expected Value]` | Decimal | The value the measure should produce                                     |

Example calculated table:

### DAX

```dax
unit_tests_2026_03_22 = DATATABLE(
    "Id", INTEGER,
    "Measure", STRING,
    "Filters", STRING,
    "WithRounding?", BOOLEAN,
    "Expected Value", DOUBLE,
    {
        { 1 ,"Total Sales", "Store[Country] = ""US""", FALSE, 1234567.89 },
        { 2 ,"AOV",         "Store[Country] = ""US""", TRUE,  42 }
    }
)
```

### Power Query

```
let
    Source = Table.FromRows(Json.Document(Binary.Decompress(Binary.FromText("i45WMlTSUQrJL0nMUQhOzEktBvKCS/KLUqOd80vzSooqYxVsFWKUQoNjlHQUnIEK8lISi6IjUxOLYm2NDIxMgcrdEnOKU4G0oZGxiamZuZ6FpVKsTrSSEVDI0T+MePMUgDJQI0OKSkEmmhgpxcYCAA==", BinaryEncoding.Base64), Compression.Deflate)), let _t = ((type nullable text) meta [Serialized.Text = true]) in type table [Id = _t, Measure = _t, Filters = _t, #"WithRounding?" = _t, #"Expected Value" = _t]),
    #"Changed Type" = Table.TransformColumnTypes(Source,{{"Id", Int64.Type}, {"Measure", type text}, {"Filters", type text}, {"WithRounding?", type logical}, {"Expected Value", type number}})
in
    #"Changed Type"
```


## Usage

### Command line

```
python -m pbi_unit_test <table_pattern> [options]
```

**Arguments**

| Argument | Description |
| --- | --- |
| `table_pattern` | Table name or glob pattern (e.g. `unit_test_*`) to run all matching tables |
| `--output-measure [FILE]` | Generate a live DAX measure. Omit `FILE` to print to stdout; provide a path to write to a file |
| `--csv FILE` | CSV file to append results to (default: `test_results.csv`) |
| `--force` | Re-run tables already recorded in the CSV, replacing their rows |

The process exits with code `0` if all tests pass, `1` if any test fails or no tables matched the pattern — suitable for CI pipelines.

---

**Use case 1 — Run a single test table**

```bash
python -m pbi_unit_test unit_tests
```

Output:

```
Connected to: MyReport.pbix

=== unit_tests ===
[PASS ✓] Total Sales | filters: Store[Country] = "US"
[FAIL ✗] AOV | filters: Store[Country] = "US"

1/2 tests passed.

Failed tests:
  AOV: expected 42, got 38.0
```

---

**Use case 2 — Run all tables matching a glob pattern**

Useful when you have one test table per sprint or date, e.g. `unit_tests_2026_03_*`.

```bash
python -m pbi_unit_test "unit_tests_*"
```

Each matching table is run in turn and results are appended to the CSV.

---

**Use case 3 — Generate a live DAX measure (print to stdout)**

Generates a `Test Status` measure you can paste directly into Power BI Desktop so you can evaluate tests interactively without re-running Python.

```bash
python -m pbi_unit_test unit_tests --output-measure
```

The generated DAX is printed between two separator lines so it is easy to copy.

---

**Use case 4 — Generate a live DAX measure and save to a file**

```bash
python -m pbi_unit_test unit_tests --output-measure status_measure.dax
```

The file can be committed to source control alongside your test table.

---

**Use case 5 — Track results in a custom CSV file**

By default results are appended to `test_results.csv` in the current directory. Use `--csv` to choose a different path.

```bash
python -m pbi_unit_test unit_tests --csv reports/q1_results.csv
```

CSV columns: `table_name`, `id`, `measure`, `filters`, `expected`, `actual`, `passed`, `error`, `dax_query`, `run_at`.

---

**Use case 6 — Re-run a table already recorded (--force)**

By default the tool skips any table whose name already appears in the CSV, preventing accidental double-runs. Use `--force` to overwrite the existing rows.

```bash
python -m pbi_unit_test unit_tests --force
```

---

**Use case 7 — CI pipeline**

The exit code makes it straightforward to fail a build when tests regress.

```bash
python -m pbi_unit_test unit_tests || exit 1
```

Or in a GitHub Actions step:

```yaml
- name: Run Power BI unit tests
  run: python -m pbi_unit_test unit_tests
```

---

**Use case 8 — Combined: run all tables, generate measure, log to custom CSV**

```bash
python -m pbi_unit_test "unit_tests_*" --output-measure status_measure.dax --csv results/history.csv --force
```

### Python API

```python
from pbi_unit_test import run_unit_tests, list_dax_measures

summary = run_unit_tests("unit_tests")
summary.print_report()

# Access individual results
for result in summary.results:
    print(result.measure, result.passed)

# Explore measures in the model
print(list_dax_measures())
```

## Project structure

```
pbi_unit_test/
├── __init__.py        # public API
├── __main__.py        # CLI entry point
├── connection.py      # port detection, ADOMD connection, DAX execution
├── measures.py        # measure listing and expression retrieval
├── runner.py          # test runner, TestResult, TestSummary
└── adom_client/       # bundled ADOMD.NET client library
    └── Microsoft.AnalysisServices.AdomdClient.dll

examples/              # example files
└── status_measure.dax # example status measure DAX
```

## Publishing to PyPI

To publish a new version:

```bash
pip install build twine
python -m build
twine upload dist/*
```
