Repository navigation
Testing Strategies
This page documents our testing infrastructure, best practices, and lessons learned about testing PDF operations.
- xUnit 2.5.3 - Test framework
- FluentAssertions 6.12.0 - Readable assertions
- Moq - Mocking for unit tests
- Avalonia.Headless.XUnit - UI testing without display
PdfEditor.Tests/
├── Integration/ # End-to-end tests
│ ├── ComprehensiveRedactionTests.cs
│ ├── GuiRedactionSimulationTests.cs
│ ├── CoordinateConversionTests.cs
│ └── BatchProcessingTests.cs
├── Unit/ # Isolated component tests
│ ├── CoordinateConverterTests.cs
│ ├── PdfOperationTests.cs
│ └── MainWindowViewModelTests.cs
├── UI/ # UI integration tests
│ ├── HeadlessUITests.cs
│ └── ViewModelIntegrationTests.cs
├── Security/ # Security-focused tests
│ └── ContentRemovalVerificationTests.cs
└── Utilities/ # Test helpers
├── TestPdfGenerator.cs
└── PdfTestHelpers.cs
The standard pattern for redaction tests:
[Fact]
public void Redact_SimpleText_RemovesFromContentStream()
{
// 1. CREATE: Generate a PDF with known content
var pdfPath = Path.Combine(_tempDir, "test.pdf");
TestPdfGenerator.CreateSimpleTextPdf(pdfPath, "Hello REDACTED World");
// Verify text is present
var textBefore = PdfTestHelpers.ExtractAllText(pdfPath);
textBefore.Should().Contain("REDACTED");
// 2. MODIFY: Perform redaction
var redactedPath = Path.Combine(_tempDir, "redacted.pdf");
RedactionService.RedactText(pdfPath, redactedPath, "REDACTED");
// 3. VERIFY: Check text is removed
var textAfter = PdfTestHelpers.ExtractAllText(redactedPath);
textAfter.Should().NotContain("REDACTED",
"Text must be REMOVED from PDF structure, not just hidden");
textAfter.Should().Contain("Hello");
textAfter.Should().Contain("World");
}Use external tools to verify true content removal:
[Fact]
public void Redact_VerifyWithPdftotext()
{
// ... create and redact ...
// Verify with pdftotext (independent extraction)
var process = Process.Start(new ProcessStartInfo
{
FileName = "pdftotext",
Arguments = $"\"{redactedPath}\" -",
RedirectStandardOutput = true,
UseShellExecute = false
});
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit();
output.Should().NotContain("REDACTED",
"pdftotext should not find redacted text");
}Test the exact workflow users follow:
[Fact]
public void GuiSimulation_SelectAndRedact()
{
// 1. Simulate loading document
var viewModel = new MainWindowViewModel();
await viewModel.LoadDocumentAsync(pdfPath);
// 2. Simulate rendering at user's DPI
var renderDpi = 150;
var pageImage = viewModel.RenderPage(0, renderDpi);
// 3. Simulate user selection (in image pixels)
var selection = new Rect(100, 200, 50, 20); // Image coordinates
// 4. Perform redaction through ViewModel
await viewModel.RedactSelectionAsync(selection, renderDpi);
// 5. Verify
var text = PdfTestHelpers.ExtractAllText(viewModel.CurrentDocument);
text.Should().NotContain("expected_redacted_word");
}Our utility for creating test PDFs with known content.
// Simple single-line text
TestPdfGenerator.CreateSimpleTextPdf(path, "Hello World");
// Multi-line text
TestPdfGenerator.CreateMultiLinePdf(path, new[] {
"Line one",
"Line two",
"Line three"
});
// Text with specific positioning
TestPdfGenerator.CreatePositionedTextPdf(path, new[] {
(100, 700, "Top text"),
(100, 400, "Middle text"),
(100, 100, "Bottom text")
});
// PDF with TJ operator (kerned text)
TestPdfGenerator.CreateKernedTextPdf(path, "WATER"); // Uses TJ
// PDF with specific font
TestPdfGenerator.CreatePdfWithFont(path, "Text", FontType.Helvetica);- Known content: Exact text positions and operators
- Reproducible: Same PDF every time
- Minimal: No unnecessary complexity
- Controlled: Test specific operators/features
Utility methods for PDF inspection.
// Extract all text from PDF
var text = PdfTestHelpers.ExtractAllText(pdfPath);
// Extract text from specific page
var pageText = PdfTestHelpers.ExtractPageText(pdfPath, pageNumber: 1);
// Get letter details
var letters = PdfTestHelpers.GetLetters(pdfPath, pageNumber: 1);
foreach (var letter in letters)
{
Console.WriteLine($"'{letter.Value}' at ({letter.X}, {letter.Y})");
}// Get raw content stream
var content = PdfTestHelpers.GetContentStream(pdfPath, pageNumber: 1);
var contentString = Encoding.UTF8.GetString(content);
// Check for specific operator
contentString.Should().Contain("Tj");// Verify PDF is valid
PdfTestHelpers.ValidatePdf(pdfPath).Should().BeTrue();
// Check page count
PdfTestHelpers.GetPageCount(pdfPath).Should().Be(3);Coordinates are a common source of bugs. Test explicitly:
[Theory]
[InlineData(0, 0, 792, 0)] // Bottom-left corner
[InlineData(0, 792, 792, 792)] // Top-left corner (PDF Y=792)
[InlineData(100, 100, 792, 692)] // Arbitrary point
public void ConvertPdfToAvalonia_CorrectY(
double pdfX, double pdfY, double pageHeight, double expectedAvaloniaY)
{
var result = CoordinateConverter.PdfToAvalonia(pdfX, pdfY, pageHeight);
result.Y.Should().BeApproximately(expectedAvaloniaY, 0.001);
}When a user reports a bug:
- Get a redacted sample (remove sensitive data)
- Document the issue
- Add to
Resources/sample-pdfs/ - Create regression test
Each sample PDF should have documentation:
### birth-certificate-request-scrambled.pdf
**Source**: Public government form (no sensitive data)
**Issue**: Letters encoded out of reading order
**Test Cases**:
- Redact "FIRST" - fails without position sorting
- Redact "MAKING" - middle of scrambled sequence[Fact]
public void RealWorld_BirthCertificate_RedactFIRST()
{
var pdfPath = "Resources/sample-pdfs/birth-certificate-request-scrambled.pdf";
// Verify precondition
var textBefore = PdfTestHelpers.ExtractAllText(pdfPath);
textBefore.Should().Contain("FIRST",
"Test precondition: PDF should contain 'FIRST'");
// Redact
var redactedPath = Path.Combine(_tempDir, "redacted.pdf");
RedactionService.RedactText(pdfPath, redactedPath, "FIRST");
// Verify
var textAfter = PdfTestHelpers.ExtractAllText(redactedPath);
textAfter.Should().NotContain("FIRST");
}[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
public class TestAppBuilder
{
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UseHeadless(new AvaloniaHeadlessPlatformOptions());
}[AvaloniaFact]
public async Task MainWindow_LoadDocument_ShowsPage()
{
var window = new MainWindow();
window.Show();
// Simulate user action
var viewModel = (MainWindowViewModel)window.DataContext;
await viewModel.LoadDocumentAsync(testPdfPath);
// Verify UI state
viewModel.IsDocumentLoaded.Should().BeTrue();
viewModel.PageCount.Should().BeGreaterThan(0);
window.Close();
}Use traits to categorize tests:
[Fact]
[Trait("Category", "Integration")]
[Trait("Feature", "Redaction")]
public void IntegrationTest() { }
[Fact]
[Trait("Category", "Unit")]
public void UnitTest() { }Run specific categories:
dotnet test --filter "Category=Integration"
dotnet test --filter "Feature=Redaction"For operations that should be fast:
[Fact]
public void Parse_LargePdf_CompletesInReasonableTime()
{
var stopwatch = Stopwatch.StartNew();
// Parse a complex PDF
var operations = parser.Parse(complexPdfContent, pageHeight);
stopwatch.Stop();
stopwatch.ElapsedMilliseconds.Should().BeLessThan(500,
"Parsing should complete in under 500ms");
}CRITICAL: Before running any redaction test on a PDF, ALWAYS verify the PDF actually contains the content type you're testing. This applies especially to corpus tests.
Running text redaction tests on PDFs with no text, or graphics redaction tests on PDFs with no graphics, wastes time and produces misleading results. This is a common mistake when iterating on corpus tests.
[Fact]
public void Redact_TextFromCorpusPdf()
{
// STEP 1: Pre-check content exists
var text = PdfTestHelpers.ExtractAllText(pdfPath);
if (string.IsNullOrWhiteSpace(text))
{
_output.WriteLine($"SKIP: {pdfPath} has no extractable text");
return; // Or use Skip.If() with xUnit
}
// STEP 2: Only now proceed with redaction test
var textToRedact = FindFirstWord(text);
// ... perform redaction ...
}When testing against a corpus like veraPDF, pre-filter files:
public static IEnumerable<object[]> GetTextContainingPdfs()
{
var corpusPath = "/path/to/corpus";
foreach (var pdf in Directory.GetFiles(corpusPath, "*.pdf"))
{
var text = PdfTestHelpers.ExtractAllText(pdf);
if (!string.IsNullOrWhiteSpace(text))
{
yield return new object[] { pdf };
}
}
}
[Theory]
[MemberData(nameof(GetTextContainingPdfs))]
public void Redact_CorpusPdfWithText(string pdfPath)
{
// Now guaranteed to have text
}| Test Type | Pre-Check | Skip If |
|---|---|---|
| Text redaction | ExtractAllText() |
Empty or whitespace |
| Image redaction | Check for /XObject in resources |
No XObjects |
| Graphics redaction | Parse content stream for path operators | No paths |
| Form XObject redaction | Check /XObject with /Subtype /Form
|
No Form XObjects |
| PDF/A compliance | Check for XMP metadata with pdfaid | Not a PDF/A file |
Add to PdfTestHelpers:
public static class PdfContentChecker
{
public static bool HasExtractableText(string pdfPath)
{
var text = ExtractAllText(pdfPath);
return !string.IsNullOrWhiteSpace(text);
}
public static bool HasFormXObjects(string pdfPath)
{
// Check resources for Form XObjects
using var doc = PdfReader.Open(pdfPath);
var page = doc.Pages[0];
var xobjects = page.Resources?.Elements.GetDictionary("/XObject");
// ... check for /Subtype /Form entries
}
public static string? GetFirstWord(string pdfPath)
{
var text = ExtractAllText(pdfPath);
if (string.IsNullOrWhiteSpace(text)) return null;
return text.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
}
}Always log why a file was skipped:
_output.WriteLine($"SKIP: {Path.GetFileName(pdfPath)} - no extractable text");
_output.WriteLine($"SKIP: {Path.GetFileName(pdfPath)} - not a PDF/A file");
_output.WriteLine($"SKIP: {Path.GetFileName(pdfPath)} - no Form XObjects");This makes test runs transparent and helps identify corpus coverage gaps.
// BAD: Tests implementation details
[Fact]
public void Parser_CallsTextBoundsCalculator()
{
mockCalculator.Verify(x => x.Calculate(...), Times.Exactly(3));
}
// GOOD: Tests behavior
[Fact]
public void Parse_TextOperators_ReturnsCorrectBoundingBoxes()
{
var operations = parser.Parse(content, pageHeight);
operations[0].BoundingBox.X.Should().BeApproximately(100, 1);
}// BAD: No context on failure
textAfter.Should().NotContain("SECRET");
// GOOD: Clear failure message
textAfter.Should().NotContain("SECRET",
"Redacted text 'SECRET' must be removed from PDF structure, not just hidden. " +
"If this fails, check that content stream is being modified, not just overlaid.");// Also test what happens when things go wrong
[Fact]
public void Redact_EmptySelection_DoesNotModifyDocument()
{
var emptyRect = new Rect(0, 0, 0, 0);
// Should not throw and should not modify
var hash1 = GetFileHash(pdfPath);
redactionService.RedactArea(page, emptyRect);
var hash2 = GetFileHash(pdfPath);
hash1.Should().Be(hash2, "Empty selection should not modify document");
}[Fact]
public void DebugFailingTest()
{
var letters = PdfTestHelpers.GetLetters(pdfPath, 1);
_output.WriteLine("Letters found:");
foreach (var letter in letters)
{
_output.WriteLine($" '{letter.Value}' at ({letter.X:F1}, {letter.Y:F1})");
}
// ... assertions ...
}[Fact]
public void SaveIntermediateFiles()
{
// Save for manual inspection
var debugDir = Path.Combine(Path.GetTempPath(), "pdf-debug");
Directory.CreateDirectory(debugDir);
File.Copy(originalPath, Path.Combine(debugDir, "original.pdf"));
File.Copy(redactedPath, Path.Combine(debugDir, "redacted.pdf"));
_output.WriteLine($"Debug files saved to: {debugDir}");
}| Category | Coverage Target |
|---|---|
| Coordinate conversion | 100% |
| Text operators (Tj, TJ, ', ") | 100% |
| Redaction pipeline | 90%+ |
| ViewModel commands | 80%+ |
| UI rendering | 60%+ |
Run coverage:
dotnet test --collect:"XPlat Code Coverage"Last updated: 2024-12-22