Daniel Rose
Daniel Rose

Reputation: 17638

Unit testing the rendering of a custom WPF UIElement

In my application I am using a custom UIElement, which handles its own layout and rendering. While I can unit test most of it, I cannot properly unit test the rendering. The reason is that rendering is done via the OnRender method, but I have no way to test what actually gets rendered, since the DrawingContext is an abstract class with an internal constructor, so I can't derive from it for testing purposes.

The only testing I know how to do is trying different scenarios (based on the implemented code) and checking that no exceptions are getting thrown. Is there any way to test more (besides using TypeMock Isolator or JustMock)?

Upvotes: 4

Views: 1798

Answers (2)

Rico-E
Rico-E

Reputation: 1845

I wrote the answer in this thread: unit testing custom OnRender-Method

But nevertheless I copy the answer to this thread as well (somebody may remove it, if not needed).

The solution is, to create the DrawingContext out of a DrawingGroup.

public class TestingMyControl : MyControl
{
    public DrawingGroup Render()
    {
        var drawingGroup = new DrawingGroup();
        using (var drawingContext = drawingGroup.Open())
        {
             base.OnRender(drawingContext);
        }
        return drawingGroup;
    }
}

So the fixture will look like:

    [Test]
    public void Should_render()
    {
        var controlToTest = new TestingMyControl();

        var drawingGroup = controlToTest.Render();

        var drawing = drawingGroup.Children[0] as GeometryDrawing;
        Assert.That(drawing.Brush, Is.EqualTo(Brushes.Black));
        Assert.That(drawing.Pen.Brush, Is.EqualTo(Brushes.SeaGreen));
        Assert.That(drawing.Pen.Thickness, Is.EqualTo(0.6));
        Assert.That(drawing.Bounds.X, Is.EqualTo(5));
        Assert.That(drawing.Bounds.Y, Is.EqualTo(15));
        Assert.That(drawing.Bounds.Width, Is.EqualTo(25));
        Assert.That(drawing.Bounds.Height, Is.EqualTo(35));
    }

This requires the follwing production code:

public class MyControl : Canvas
{
    protected override void OnRender(DrawingContext dc)
    {
        dc.DrawRectangle(Brushes.Black, new Pen(Brushes.SeaGreen, 0.6), new Rect(5, 15, 25, 35));
    }
}

Upvotes: 5

123 456 789 0
123 456 789 0

Reputation: 10865

Unfortunately, you must call InvalidateVisual, which calls InvalidateArrange internally. The OnRender method is called as part of the arrange phase, so you need to tell WPF to rearrange the control (which InvalidateArrange does) and that it needs to redraw (which InvalidateVisual does).

The FrameworkPropertyMetadata.AffectsRender option simply tells WPF to call InvalidateVisual when the associated property changes.

If you have a control (let's call this MainControl) that overrides OnRender and contains several descendant controls, then calling InvalidateVisual may require the descendant controls to be rearranged, or even remeasured. But I believe WPF has optimizations inplace to prevent descendant controls from being rearranged if their available space is unchanged.

You may be able to get around this by moving your rendering logic to a separate control (say NestedControl), which would be a visual child of MainControl. The MainControl could add this as a visual child automatically or as part of it's ControlTemplate, but it would need to be the lowest child in the z-order. You could then expose a InvalidateNestedControl type method on MainControl that would call InvalidateVisual on the NestedControl.

Here's what I did. To test this, I created this subclass...

public class TestPanel : DockPanel
{
    protected override Size MeasureOverride(Size constraint)
    {
        System.Console.WriteLine("MeasureOverride called for " + this.Name + ".");
        return base.MeasureOverride(constraint);
    }
protected override System.Windows.Size ArrangeOverride(System.Windows.Size arrangeSize)
{
    System.Console.WriteLine("ArrangeOverride called for " + this.Name + ".");
    return base.ArrangeOverride(arrangeSize);
}

protected override void OnRender(System.Windows.Media.DrawingContext dc)
{
    System.Console.WriteLine("OnRender called for " + this.Name + ".");
    base.OnRender(dc);
}

}

...which I laid out like this (note that they are nested):

<Button Content="Test" Click="Button_Click" DockPanel.Dock="Top" HorizontalAlignment="Left" />

<l:TestPanel x:Name="InnerPanel" Background="Red" Margin="16" />

When I resized the window, I got this...

MeasureOverride called for MainTestPanel. MeasureOverride called for InnerPanel. ArrangeOverride called for MainTestPanel. ArrangeOverride called for InnerPanel. OnRender called for InnerPanel. OnRender called for MainTestPanel. but when I called InvalidateVisual on 'MainTestPanel' (in the button's 'Click' event), I got this instead...

ArrangeOverride called for MainTestPanel. OnRender called for MainTestPanel. Note how none of the measuring overrides were called, and only the ArrangeOverride for the outer control was called.

It's not perfect as if you have a very heavy calculation inside ArrangeOverride in your subclass (which unfortunately we do) that still gets (re)executed, but at least the children don't fall to the same fate.

However, if you know none of the child controls have a property with the AffectsParentArrange bit set (again, which we do), you can go one better and use a Nullable Size as a flag to suppress the ArrangeOverride logic from re-entry except when needed, like so...

public class TestPanel : DockPanel
{
    Size? arrangeResult;

protected override Size MeasureOverride(Size constraint)
{
    arrangeResult = null;
    System.Console.WriteLine("MeasureOverride called for " + this.Name + ".");
    return base.MeasureOverride(constraint);
}

protected override System.Windows.Size ArrangeOverride(System.Windows.Size arrangeSize)
{
    if(!arrangeResult.HasValue)
    {
        System.Console.WriteLine("ArrangeOverride called for " + this.Name + ".");
        // Do your arrange work here
        arrangeResult = base.ArrangeOverride(arrangeSize);
    }

    return arrangeResult.Value;
}

protected override void OnRender(System.Windows.Media.DrawingContext dc)
{
    System.Console.WriteLine("OnRender called for " + this.Name + ".");
    base.OnRender(dc);
}

}

Now unless something specifically needs to re-execute the arrange logic (as a call to MeasureOverride does) you only get OnRender, and if you want to explicitly force the Arrange logic, simply null out the size, call InvalidateVisual.

Upvotes: 0

Related Questions