Rubberduck's unit testing feature (heck, the whole project) started with a VBA unit testing framework that was ported from vba to c#; 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:
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.
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?