8
\$\begingroup\$

Rubberduck's unit testing feature (heck, the whole project) started with a VBA unit testing framework that was ported from to ; the VBA logic was pretty much simply rewritten in another language, and a dockable toolwindow is now available in the VBA IDE for Rubberduck users around the world:

Rubberduck 1.x Test Explorer

For the next major release, the Test Explorer is getting a facelift. What was a WinForms user control in 1.x is going to be a WPF user control in 2.x - in fact as much as possible of the UI is going to be redone in WPF/XAML, and hosted in a WinForms/WPF control host.

Rubberduck 2.0 Test Explorer

Here's the markup for the Rubberduck 2.0 Test Explorer:

<UserControl x:Class="Rubberduck.UI.UnitTesting.TestExplorerControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:Rubberduck.UI.UnitTesting"
             xmlns:unitTesting="clr-namespace:Rubberduck.UnitTesting"
             xmlns:resx="clr-namespace:Rubberduck.UI"
             xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
             mc:Ignorable="d" 
             d:DesignHeight="255" d:DesignWidth="255"
             MinWidth="255" MinHeight="255" d:DataContext="{d:DesignInstance local:TestExplorerViewModel}">

    <UserControl.Resources>

        <Style x:Key="IconStyle" TargetType="Image">
            <Setter Property="Height" Value="16" />
            <Setter Property="Width" Value="16" />
            <Setter Property="Margin" Value="4" />
        </Style>

        <unitTesting:TestOutcomeImageSourceConverter x:Key="OutcomeIconConverter" />

        <CollectionViewSource x:Key="OutcomeGroupViewSource" Source="{Binding Model.Tests}">
            <CollectionViewSource.GroupDescriptions>
                <PropertyGroupDescription PropertyName="Outcome" />
            </CollectionViewSource.GroupDescriptions>
        </CollectionViewSource>

        <DataTemplate x:Key="TestMethodTemplate" DataType="{x:Type unitTesting:TestMethod}">
            <StackPanel Orientation="Horizontal">
                <Image Style="{StaticResource IconStyle}" 
                       Source="{Binding Outcome, Converter={StaticResource OutcomeIconConverter}}"
                       VerticalAlignment="Center" />
                <TextBlock Margin="4" 
                           VerticalAlignment="Center" 
                           Text="{Binding QualifiedMemberName}" 
                           TextWrapping="NoWrap"/>
            </StackPanel>
        </DataTemplate>

        <HierarchicalDataTemplate x:Key="OutcomeTemplate" 
                                    DataType="{x:Type CollectionViewGroup}"
                                    ItemsSource="{Binding Items}" 
                                    ItemTemplate="{StaticResource TestMethodTemplate}">

            <StackPanel Orientation="Horizontal">
                <TextBlock Margin="4" 
                           VerticalAlignment="Center" 
                           Text="{Binding Name}"
                           FontWeight="Bold"
                           TextWrapping="NoWrap"/>
                <TextBlock Margin="4" 
                           VerticalAlignment="Center" 
                           Text="{Binding ItemCount, StringFormat=({0})}" 
                           TextWrapping="NoWrap"/>
            </StackPanel>
        </HierarchicalDataTemplate>

    </UserControl.Resources>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="30"/>
            <RowDefinition Height="16"/>
            <RowDefinition Height="*" MinHeight="64" />
            <RowDefinition Height="5"/>
            <RowDefinition Height="Auto" MinHeight="48"/>
        </Grid.RowDefinitions>

        <Border Grid.Row="0" Grid.RowSpan="5" Background="#FFEEF5FD" />

        <ToolBar Grid.Row="0">

            <Button Command="{Binding RefreshCommand}">
                <Image Height="16" Source="../../Resources/arrow-circle-double.png" />
            </Button>

            <Separator />

            <Menu>
                <MenuItem VerticalAlignment="Center" Header="{x:Static resx:RubberduckUI.Run}">
                    <MenuItem.Icon>
                        <Image Height="16" Source="../../Resources/control.png" />
                    </MenuItem.Icon>
                    <MenuItem Command="{Binding RunAllTestsCommand}" Header="{x:Static resx:RubberduckUI.TestExplorer_RunAllTests}">
                        <MenuItem.Icon>
                            <Image Height="16" Source="../../Resources/flask--arrow.png" />
                        </MenuItem.Icon>
                    </MenuItem>
                    <Separator />
                    <MenuItem Command="{Binding RunNotExecutedTestsCommand}" Header="{x:Static resx:RubberduckUI.TestExplorer_RunNotRunTests}" >
                        <MenuItem.Icon>
                            <Image Height="16" Source="../../Resources/question-white.png" />
                        </MenuItem.Icon>
                    </MenuItem>
                    <MenuItem Command="{Binding RunPassedTestsCommand}" Header="{x:Static resx:RubberduckUI.TestExplorer_RunPassedTests}" >
                        <MenuItem.Icon>
                            <Image Height="16" Source="../../Resources/tick-circle.png" />
                        </MenuItem.Icon>
                    </MenuItem>
                    <MenuItem Command="{Binding RunFailedTestsCommand}" Header="{x:Static resx:RubberduckUI.TestExplorer_RunFailedTests}">
                        <MenuItem.Icon>
                            <Image Height="16" Source="../../Resources/cross-circle.png" />
                        </MenuItem.Icon>
                    </MenuItem>
                    <Separator />
                    <MenuItem Command="{Binding RepeatLastRunCommand}" Header="{x:Static resx:RubberduckUI.TestExplorer_RunLastRunTests}">
                        <MenuItem.Icon>
                            <Image Height="16" Source="../../Resources/arrow-repeat.png" />
                        </MenuItem.Icon>
                    </MenuItem>

                </MenuItem>
                <MenuItem VerticalAlignment="Center" Header="{x:Static resx:RubberduckUI.Add}">
                    <MenuItem.Icon>
                        <Image Height="16" Source="../../Resources/flask--plus.png" />
                    </MenuItem.Icon>
                    <MenuItem Command="{Binding AddTestModuleCommand}" Header="{x:Static resx:RubberduckUI.TestExplorer_AddTestModule}">
                    </MenuItem>
                    <Separator />
                    <MenuItem Command="{Binding AddTestMethodCommand}" Header="{x:Static resx:RubberduckUI.TestExplorer_AddTestMethod}">
                        <MenuItem.Icon>
                            <Image Height="16" Source="../../Resources/flask.png" />
                        </MenuItem.Icon>
                    </MenuItem>
                    <MenuItem Command="{Binding AddErrorTestMethodCommand}" Header="{x:Static resx:RubberduckUI.TestExplorer_AddExpectedErrorTestMethod}">
                        <MenuItem.Icon>
                            <Image Height="16" Source="../../Resources/flask--exclamation.png" />
                        </MenuItem.Icon>
                    </MenuItem>
                </MenuItem>
            </Menu>
            <Separator />

            <Button Command="{Binding CopyResultsCommand}">
                <Image Height="16" Source="../../Resources/document-copy.png" />
            </Button>

            <Button Command="{Binding ExportResultsCommand}">
                <Image Height="16" Source="../../Resources/disk.png" />
            </Button>

        </ToolBar>

        <Border Grid.Row="1" Margin="2">
            <StackPanel>

                <Border BorderBrush="DimGray" BorderThickness="1,1,0,0">
                    <ProgressBar Height="12" 
                                 Background="DimGray" 
                                 Foreground="LawnGreen"
                                 Maximum="{Binding Model.TestCount, Mode=OneWay}" 
                                 Value="{Binding Model.ExecutedCount, Mode=OneWay}"/>
                </Border>

            </StackPanel>
        </Border>

        <TreeView Grid.Row="2" x:Name="TestMethodTree"
                  MouseDoubleClick="TreeView_MouseDoubleClick"
                  ItemsSource="{Binding Source={StaticResource OutcomeGroupViewSource}, Path=Groups}"
                  ItemTemplate="{StaticResource OutcomeTemplate}">
            <i:Interaction.Behaviors>
                <local:BindableSelectedItemBehavior SelectedItem="{Binding SelectedItem, Mode=TwoWay}" />
            </i:Interaction.Behaviors>
        </TreeView>

        <GridSplitter Grid.Row="3" Height="5" ShowsPreview="True" Cursor="SizeNS" HorizontalAlignment="Stretch"/>

        <Border Grid.Row="4" BorderThickness="0,1,0,0" BorderBrush="DimGray">

            <StackPanel Orientation="Vertical" MinHeight="48" Background="Gainsboro">
                <StackPanel Margin="4" Orientation="Horizontal" HorizontalAlignment="Stretch">
                    <Image Style="{StaticResource IconStyle}" VerticalAlignment="Center" 
                           Source="{Binding SelectedItem.Result.Outcome, Converter={StaticResource OutcomeIconConverter}}"/>
                    <TextBlock Margin="4" Text="{Binding SelectedItem.QualifiedMemberName}" FontWeight="Black" TextWrapping="Wrap"/>
                </StackPanel>
                <TextBlock Margin="32,0,0,0" Text="{Binding SelectedItem.Result.Output}" TextWrapping="Wrap" Foreground="DarkSlateGray"/>
            </StackPanel>
        </Border>
    </Grid>
</UserControl>

One concern I have, is that I'm calling ICollectionView.Refresh() everytime a test finishes, which doesn't feel like it's optimal, especially given that I'm passing the TestMethod object that has just executed... but I haven't managed to get the groupings to refresh using that test parameter in UpdateTestMethod. Is it a problem to leave the unused parameter in place?

public partial class TestExplorerControl : UserControl
{
    public TestExplorerControl()
    {
        InitializeComponent();
        DataContextChanged += TestExplorerControl_DataContextChanged;
    }

    private void TestExplorerControl_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
    {
        var oldValue = e.OldValue as TestExplorerViewModel;
        if (oldValue != null)
        {
            oldValue.TestCompleted -= ViewModel_TestCompleted;
        }

        var newValue = e.NewValue as TestExplorerViewModel;
        if (newValue == null)
        {
            return;
        }

        newValue.TestCompleted += ViewModel_TestCompleted;
    }

    private void ViewModel_TestCompleted(object sender, TestCompletedEventArgs e)
    {
        UpdateTestMethod(e.Test);
    }

    private TestExplorerViewModel Context { get { return DataContext as TestExplorerViewModel; } }

    private void UpdateTestMethod(TestMethod test)
    {
        var view = (CollectionViewSource)Resources["OutcomeGroupViewSource"];
        view.View.Refresh();
    }

    private void TreeView_MouseDoubleClick(object sender, MouseButtonEventArgs e)
    {
        if (Context == null)
        {
            return;
        }

        var selection = Context.SelectedItem;
        if (selection == null)
        {
            return;
        }

        Context.NavigateCommand.Execute(selection.GetNavigationArgs());
    }
}

And here is the TestExplorerViewModel, which is assigned to the DataContext of the XAML control:

public class TestExplorerViewModel : ViewModelBase
{
    private readonly ITestEngine _testEngine;
    private readonly TestExplorerModelBase _model;

    public TestExplorerViewModel(VBE vbe, ITestEngine testEngine, ICodePaneWrapperFactory wrapper, TestExplorerModelBase model)
    {
        _testEngine = testEngine;
        _testEngine.TestCompleted += TestEngineTestCompleted;
        _model = model;

        _navigateCommand = new NavigateCommand(wrapper);

        _runAllTestsCommand = new RunAllTestsCommand(testEngine, model);
        _addTestModuleCommand = new AddTestModuleCommand(vbe);
        _addTestMethodCommand = new AddTestMethodCommand(vbe, model);
        _addErrorTestMethodCommand = new AddTestMethodExpectedErrorCommand(vbe, model);

        _refreshCommand = new DelegateCommand(ExecuteRefreshCommand, CanExecuteRefreshCommand);
        _repeatLastRunCommand = new DelegateCommand(ExecuteRepeatLastRunCommand);
        _runNotExecutedTestsCommand = new DelegateCommand(ExecuteRunNotExecutedTestsCommand);
        _runFailedTestsCommand = new DelegateCommand(ExecuteRunFailedTestsCommand);
        _runPassedTestsCommand = new DelegateCommand(ExecuteRunPassedTestsCommand);

        _copyResultsCommand = new DelegateCommand(ExecuteCopyResultsCommand);
        _exportResultsCommand = new DelegateCommand(ExecuteExportResultsCommand);
    }

    public event EventHandler<TestCompletedEventArgs> TestCompleted;
    private void TestEngineTestCompleted(object sender, TestCompletedEventArgs e)
    {
        var handler = TestCompleted;
        if (handler != null)
        {
            handler.Invoke(this, e);
        }
    }

    private TestMethod _selectedItem;
    public TestMethod SelectedItem
    {
        get { return _selectedItem; }
        set
        {
            _selectedItem = value;
            OnPropertyChanged();
        }
    }

    private readonly ICommand _runAllTestsCommand;
    public ICommand RunAllTestsCommand { get { return _runAllTestsCommand; } }

    private readonly ICommand _addTestModuleCommand;
    public ICommand AddTestModuleCommand { get { return _addTestModuleCommand; } }

    private readonly ICommand _addTestMethodCommand;
    public ICommand AddTestMethodCommand { get { return _addTestMethodCommand; } }

    private readonly ICommand _addErrorTestMethodCommand;
    public ICommand AddErrorTestMethodCommand { get { return _addErrorTestMethodCommand; } }

    private readonly ICommand _refreshCommand;
    public ICommand RefreshCommand { get { return _refreshCommand; } }

    private readonly ICommand _repeatLastRunCommand;
    public ICommand RepeatLastRunCommand { get { return _repeatLastRunCommand; } }

    private readonly ICommand _runNotExecutedTestsCommand;
    public ICommand RunNotExecutedTestsCommand { get { return _runNotExecutedTestsCommand; } }

    private readonly ICommand _runFailedTestsCommand;
    public ICommand RunFailedTestsCommand { get { return _runFailedTestsCommand; } }

    private readonly ICommand _runPassedTestsCommand;
    public ICommand RunPassedTestsCommand { get { return _runPassedTestsCommand; } }

    private readonly ICommand _copyResultsCommand;
    public ICommand CopyResultsCommand { get { return _copyResultsCommand; } }

    private readonly ICommand _exportResultsCommand;
    public ICommand ExportResultsCommand { get { return _exportResultsCommand; } }

    private readonly NavigateCommand _navigateCommand;
    public ICommand NavigateCommand { get { return _navigateCommand; } }

    private bool _isBusy;

    public bool IsBusy 
    { 
        get { return _isBusy; }
        private set
        {
            _isBusy = value; 
            OnPropertyChanged(); 
        } 
    }

    public TestExplorerModelBase Model { get { return _model; } }

    private void ExecuteRefreshCommand(object parameter)
    {
        if (_isBusy)
        {
            return;
        }

        IsBusy = true;
        _model.Refresh();
        SelectedItem = null;
        IsBusy = false;
    }

    private void EvaluateCanExecute()
    {
        Dispatcher.CurrentDispatcher.Invoke(CommandManager.InvalidateRequerySuggested);
    }

    private bool CanExecuteRefreshCommand(object parameter)
    {
        return !IsBusy;
    }

    private void ExecuteRepeatLastRunCommand(object parameter)
    {
        IsBusy = true;
        _testEngine.Run(_model.Tests.Where(test => test.Result.Outcome != TestOutcome.Unknown));
        IsBusy = false;
        EvaluateCanExecute();
    }

    private void ExecuteRunNotExecutedTestsCommand(object parameter)
    {
        IsBusy = true;
        _testEngine.Run(_model.Tests.Where(test => test.Result.Outcome == TestOutcome.Unknown));
        IsBusy = false;
        EvaluateCanExecute();
    }

    private void ExecuteRunFailedTestsCommand(object parameter)
    {
        IsBusy = true;
        _testEngine.Run(_model.Tests.Where(test => test.Result.Outcome == TestOutcome.Failed));
        IsBusy = false;
        EvaluateCanExecute();
    }

    private void ExecuteRunPassedTestsCommand(object parameter)
    {
        IsBusy = true;
        _testEngine.Run(_model.Tests.Where(test => test.Result.Outcome == TestOutcome.Succeeded));
        IsBusy = false;
        EvaluateCanExecute();
    }

    private void ExecuteExportResultsCommand(object parameter)
    {
        throw new NotImplementedException();
    }

    private void ExecuteCopyResultsCommand(object parameter)
    {
        throw new NotImplementedException();
    }
}

For this to work, I changed the TestMethod class to derive from ViewModelBase (to get the nifty OnPropertyChanged() magic), and made it implement IEditableObject - the actual logic didn't need to change:

public class TestMethod : ViewModelBase, IEquatable<TestMethod>, IEditableObject
{
    private readonly ICollection<TestResult> _assertResults = new List<TestResult>();
    private readonly IHostApplication _hostApp;

    public TestMethod(QualifiedMemberName qualifiedMemberName, VBE vbe)
    {
        _qualifiedMemberName = qualifiedMemberName;
        _vbe = vbe;
        _hostApp = vbe.HostApplication();
    }

    private readonly QualifiedMemberName _qualifiedMemberName;
    private readonly VBE _vbe;
    public QualifiedMemberName QualifiedMemberName { get { return _qualifiedMemberName; } }

    public QualifiedModuleName QualifiedModuleName { get { return _qualifiedMemberName.QualifiedModuleName; } }

    public void Run()
    {
        _assertResults.Clear(); //clear previous results to account for changes being made

        TestResult result;
        var duration = new TimeSpan();
        try
        {
            AssertHandler.OnAssertCompleted += HandleAssertCompleted;
            duration = _hostApp.TimedMethodCall(_qualifiedMemberName);
            AssertHandler.OnAssertCompleted -= HandleAssertCompleted;

            result = EvaluateResults();
        }
        catch(Exception exception)
        {
            result = TestResult.Inconclusive("Test raised an error. " + exception.Message);
        }

        Result = new TestResult(result, duration.Milliseconds);
    }

    private TestResult _result = new TestResult(TestOutcome.Unknown);

    public TestOutcome Outcome { get { return _result.Outcome; } }

    public TestResult Result
    {
        get { return _result; } 
        set { _result = value; OnPropertyChanged();}
    }

    void HandleAssertCompleted(object sender, AssertCompletedEventArgs e)
    {
        _assertResults.Add(e.Result);
    }

    private TestResult EvaluateResults()
    {
        var result = TestResult.Success();

        if (_assertResults.Any(assertion => assertion.Outcome == TestOutcome.Failed || assertion.Outcome == TestOutcome.Inconclusive))
        {
            result = _assertResults.First(assertion => assertion.Outcome == TestOutcome.Failed || assertion.Outcome == TestOutcome.Inconclusive);
        }

        return result;
    }

    public NavigateCodeEventArgs GetNavigationArgs()
    {
        try
        {
            var moduleName = QualifiedMemberName.QualifiedModuleName;
            var methodName = QualifiedMemberName.MemberName;

            var module = _vbe.VBProjects.Cast<VBProject>()
                .Single(project => project == QualifiedMemberName.QualifiedModuleName.Project)
                .VBComponents.Cast<VBComponent>()
                .Single(component => component.Name == QualifiedMemberName.QualifiedModuleName.ComponentName)
                .CodeModule;

            var startLine = module.get_ProcStartLine(methodName, vbext_ProcKind.vbext_pk_Proc);
            var endLine = startLine + module.get_ProcCountLines(methodName, vbext_ProcKind.vbext_pk_Proc);
            var endLineColumns = module.get_Lines(endLine, 1).Length;

            var selection = new Selection(startLine, 1, endLine, endLineColumns == 0 ? 1 : endLineColumns);
            return new NavigateCodeEventArgs(new QualifiedSelection(moduleName, selection));
        }
        catch (COMException)
        {
            return null;
        }
    }

    public bool Equals(TestMethod other)
    {
        return QualifiedMemberName.Equals(other.QualifiedMemberName);
    }

    public override bool Equals(object obj)
    {
        return obj is TestMethod
            && ((TestMethod)obj).QualifiedMemberName.Equals(QualifiedMemberName);
    }

    public override int GetHashCode()
    {
        return QualifiedMemberName.GetHashCode();
    }

    private TestResult _cachedResult;

    private bool _isEditing;
    public bool IsEditing { get { return _isEditing; } set { _isEditing = value; OnPropertyChanged(); } }

    public void BeginEdit()
    {
        _cachedResult = new TestResult(Result, Result.Duration);
        IsEditing = true;
    }

    public void EndEdit()
    {
        _cachedResult = null;
        IsEditing = false;
    }

    public void CancelEdit()
    {
        if (_cachedResult != null)
        {
            Result = _cachedResult;
        }

        _cachedResult = null;
        IsEditing = false;
    }
}

Should I have implemented a separate class? Does GetNavigationArgs belong there?

Anything else?

\$\endgroup\$
  • 1
    \$\begingroup\$ How about invalidating the control's display rather than forcing a redraw immediately? \$\endgroup\$ – Mario Aug 30 '15 at 6:39
  • \$\begingroup\$ @Mario yes, absolutely. I just haven't managed to get that to work. \$\endgroup\$ – Mathieu Guindon Aug 30 '15 at 17:34
7
\$\begingroup\$

TestMethod

The Result property (and IsEditing property)

public TestResult Result
{
    get { return _result; } 
    set { _result = value; OnPropertyChanged();}
}  

seems only to be set by the TestMethod object, so there is no need to have a public setter there. Making the setter either private or protected will increase its encapsulation.

The usage of OnPropertyChanged() should only be done if the property really changed, so changing it to

public TestResult Result
{
    get { return _result; } 
    private set 
    { 
        if(_result == value) { return; }
        _result = value; 
        OnPropertyChanged();
    }
}  

will remove unneeded calls of said event.


private TestResult EvaluateResults()
{
    var result = TestResult.Success();

    if (_assertResults.Any(assertion => assertion.Outcome == TestOutcome.Failed || assertion.Outcome == TestOutcome.Inconclusive))
    {
        result = _assertResults.First(assertion => assertion.Outcome == TestOutcome.Failed || assertion.Outcome == TestOutcome.Inconclusive);
    }

    return result;
}  

Here you are first checking with Any() if the wanted item exists and later you are calling First() to retrieve the first item.

A much easier way would be to just use FirstOrDefault() and using the null coalescing operator to return either the found item or TestResult.Success() like so

private TestResult EvaluateResults()
{
    var result = _assertResults.FirstOrDefault(assertion => assertion.Outcome == TestOutcome.Failed || assertion.Outcome == TestOutcome.Inconclusive);

    return result ?? TestResult.Success();
}  

public override bool Equals(object obj)
{
    return obj is TestMethod
        && ((TestMethod)obj).QualifiedMemberName.Equals(QualifiedMemberName);
}  

I don't like the usage of is together with a cast because it is just doing the cast two times. A better way would be to use as together with a null check like so

public override bool Equals(object obj)
{
    var method = obj as TestMethod;
    if (method == null) { return false; }

    return method.QualifiedMemberName.Equals(QualifiedMemberName);
}  

public void CancelEdit()
{
    if (_cachedResult != null)
    {
        Result = _cachedResult;
    }

    _cachedResult = null;
    IsEditing = false;
}  

here the setting of _cachedResult to null should be done inside the if. There is no need to set an object which is null to null.

| improve this answer | |
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.