-
Notifications
You must be signed in to change notification settings - Fork 6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
OSOE-353: Add duplicate SQL query detector in Lombiq.UITestingToolbox #216
base: dev
Are you sure you want to change the base?
Changes from 53 commits
f224aff
d87e325
34b538b
7574e9a
beef486
6c3602f
06f8eaa
3cd03e3
8335d59
914c291
1100237
df3c3c2
1a0fd1a
8e61c22
a20311c
f033443
cac8255
169fa26
cc941b0
8e854fc
dff34c3
33fd4fe
f0d8fc0
0a8189c
d0a088f
5d32c2f
448a46e
0e1cdfe
eb8adc9
b416eb5
bc91080
b3e0600
278fdcc
2fd58bd
98a596e
8c05e71
097d091
030c692
406e6ed
6bf2917
acf440f
3342c9d
126c97b
82c9090
f50c13c
8a0b33c
de38ccd
8561fac
fb9be54
4935a11
c2906c7
8c58730
c953c8c
3343bd8
4f334fe
027486d
fa3a95c
0e4f177
849553f
9203e36
a52cff1
30b4997
2d5abae
1b500d8
bca3add
a1531d5
71663b9
1dea988
2c7e741
a6f685c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,8 +20,21 @@ public BasicOrchardFeaturesTests(ITestOutputHelper testOutputHelper) | |
// We could reuse the previously specified SetupHelpers.RecipeId const here but it's actually a different recipe for | ||
// this test. | ||
[Fact] | ||
public Task BasicOrchardFeaturesShouldWork() => | ||
ExecuteTestAsync(context => context.TestBasicOrchardFeaturesAsync(RecipeIds.BasicOrchardFeaturesTests)); | ||
public Task BasicOrchardFeaturesShouldWork(Browser browser) => | ||
ExecuteTestAsync( | ||
context => context.TestBasicOrchardFeaturesAsync(RecipeIds.BasicOrchardFeaturesTests), | ||
|
||
configuration => | ||
{ | ||
// The UI Testing Toolbox includes a DbCommand execution counter to check for duplicated SQL queries.. | ||
// After the end of the test, it checks the number of executed commands with the same SQL command text | ||
// and parameter set against the threshold value in its configuration. If the executed command count is | ||
// greater than the threshold, it raises a CounterThresholdException. So here we set the minimum | ||
// required value to avoid it. | ||
configuration.CounterConfiguration.Running.PhaseThreshold.DbCommandExecutionThreshold = 26; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this configuration needed in this test? |
||
|
||
return Task.CompletedTask; | ||
}); | ||
} | ||
|
||
// END OF TRAINING SECTION: Basic Orchard features tests. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
using Lombiq.Tests.UI.Attributes; | ||
using Lombiq.Tests.UI.Extensions; | ||
using Lombiq.Tests.UI.Services; | ||
using Lombiq.Tests.UI.Services.Counters.Configuration; | ||
using Shouldly; | ||
using System; | ||
using System.Threading.Tasks; | ||
using Xunit; | ||
using Xunit.Abstractions; | ||
|
||
namespace Lombiq.Tests.UI.Samples.Tests; | ||
|
||
// Some times you may want to detect duplicated SQL queries. This can be useful if you want to make sure that your code | ||
// does not execute the same query multiple times, wasting time and computing resources. | ||
public class DuplicatedSqlQueryDetectorTests : UITestBase | ||
{ | ||
public DuplicatedSqlQueryDetectorTests(ITestOutputHelper testOutputHelper) | ||
: base(testOutputHelper) | ||
{ | ||
} | ||
|
||
// This test will fail because the app will read the same command result more times than the configured threshold | ||
// during the Admin page rendering. | ||
[Theory, Chrome] | ||
public Task PageWithTooManyDuplicatedSqlQueriesShouldThrow(Browser browser) => | ||
Should.ThrowAsync<AggregateException>(() => | ||
ExecuteTestAfterSetupAsync( | ||
context => context.SignInDirectlyAndGoToDashboardAsync(), | ||
browser, | ||
ConfigureAsync)); | ||
|
||
// This test will pass because not the Admin page was loaded. | ||
[Theory, Chrome] | ||
public Task PageWithoutDuplicatedSqlQueriesShouldPass(Browser browser) => | ||
Should.NotThrowAsync(() => | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
ExecuteTestAfterSetupAsync( | ||
async context => await context.GoToHomePageAsync(onlyIfNotAlreadyThere: false), | ||
browser, | ||
ConfigureAsync)); | ||
|
||
// We configure the test to throw an exception if a certain counter threshold is exceeded, but only in case of Admin | ||
// pages. | ||
private static Task ConfigureAsync(OrchardCoreUITestExecutorConfiguration configuration) | ||
{ | ||
// The test is guaranteed to fail so we don't want to retry it needlessly. | ||
configuration.MaxRetryCount = 0; | ||
|
||
var adminCounterConfiguration = new CounterConfiguration(); | ||
// Let's enable and configure the counter threshold for ORM sessions. | ||
adminCounterConfiguration.SessionThreshold.Disable = false; | ||
adminCounterConfiguration.SessionThreshold.DbReaderReadThreshold = 0; | ||
// Apply the configuration to the Admin pages only. | ||
configuration.CounterConfiguration.Running.Add( | ||
new RelativeUrlConfigurationKey(new Uri("/Admin", UriKind.Relative), exactMatch: false), | ||
adminCounterConfiguration); | ||
|
||
return Task.CompletedTask; | ||
} | ||
} | ||
|
||
// END OF TRAINING SECTION: Duplicated SQL query detector. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
using Lombiq.Tests.UI.Services.Counters; | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Text; | ||
|
||
namespace Lombiq.Tests.UI.Exceptions; | ||
|
||
public class CounterThresholdException : Exception | ||
{ | ||
public CounterThresholdException() | ||
{ | ||
} | ||
|
||
public CounterThresholdException(string message) | ||
: this(probe: null, counter: null, value: null, message) | ||
{ | ||
} | ||
|
||
public CounterThresholdException(string message, Exception innerException) | ||
: this(probe: null, counter: null, value: null, message, innerException) | ||
{ | ||
} | ||
|
||
public CounterThresholdException( | ||
ICounterProbe probe, | ||
ICounterKey counter, | ||
ICounterValue value) | ||
: this(probe, counter, value, message: null, innerException: null) | ||
{ | ||
} | ||
|
||
public CounterThresholdException( | ||
ICounterProbe probe, | ||
ICounterKey counter, | ||
ICounterValue value, | ||
string message) | ||
: this(probe, counter, value, message, innerException: null) | ||
{ | ||
} | ||
|
||
public CounterThresholdException( | ||
ICounterProbe probe, | ||
ICounterKey counter, | ||
ICounterValue value, | ||
string message, | ||
Exception innerException) | ||
: base(FormatMessage(probe, counter, value, message), innerException) | ||
{ | ||
} | ||
|
||
private static string FormatMessage( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Generate exception messages that are easier to understand than:
I as an ordinary developer who didn't set up these counter thresholds but just wanted to change something unrelated but accidentally implemented a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While the current message is better, it's still cryptic when you first see it.
Start the exception with a message that explains, in plain English, what happened. |
||
ICounterProbe probe, | ||
ICounterKey counter, | ||
ICounterValue value, | ||
string message) | ||
{ | ||
var builder = new StringBuilder(); | ||
if (probe is not null) builder.AppendLine(probe.DumpHeadline()); | ||
counter?.Dump().ForEach(line => builder.AppendLine(line)); | ||
value?.Dump().ForEach(line => builder.AppendLine(line)); | ||
if (!string.IsNullOrEmpty(message)) builder.AppendLine(message); | ||
|
||
return builder.ToString(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
using Lombiq.Tests.UI.Services.Counters; | ||
using System; | ||
using System.Collections.Concurrent; | ||
using System.Collections.Generic; | ||
using System.Linq; | ||
using Xunit.Abstractions; | ||
|
||
namespace Lombiq.Tests.UI.Services; | ||
|
||
public sealed class CounterDataCollector : CounterProbeBase, ICounterDataCollector | ||
{ | ||
private readonly ITestOutputHelper _testOutputHelper; | ||
private readonly ConcurrentBag<ICounterProbe> _probes = new(); | ||
private readonly ConcurrentBag<Exception> _postponedCounterExceptions = new(); | ||
public override bool IsAttached => true; | ||
public Action<ICounterDataCollector, ICounterProbe> AssertCounterData { get; set; } | ||
public string Phase { get; set; } | ||
|
||
public CounterDataCollector(ITestOutputHelper testOutputHelper) => | ||
_testOutputHelper = testOutputHelper; | ||
|
||
public void AttachProbe(ICounterProbe probe) | ||
{ | ||
probe.CaptureCompleted = ProbeCaptureCompleted; | ||
_probes.Add(probe); | ||
} | ||
|
||
public void Reset() | ||
{ | ||
_probes.Clear(); | ||
_postponedCounterExceptions.Clear(); | ||
Clear(); | ||
} | ||
|
||
public override void Increment(ICounterKey counter) | ||
{ | ||
_probes.Where(probe => probe.IsAttached) | ||
.ForEach(probe => probe.Increment(counter)); | ||
base.Increment(counter); | ||
} | ||
|
||
public override string DumpHeadline() => $"{nameof(CounterDataCollector)}, Phase = {Phase}"; | ||
|
||
public override IEnumerable<string> Dump() | ||
{ | ||
var lines = new List<string> | ||
{ | ||
DumpHeadline(), | ||
}; | ||
|
||
lines.AddRange(DumpSummary().Select(line => $"\t{line}")); | ||
|
||
return lines; | ||
} | ||
|
||
public void AssertCounter(ICounterProbe probe) => AssertCounterData?.Invoke(this, probe); | ||
|
||
public void AssertCounter() | ||
{ | ||
if (_postponedCounterExceptions.Any()) | ||
{ | ||
throw new AggregateException( | ||
"There were exceptions out of the test execution context.", | ||
_postponedCounterExceptions); | ||
} | ||
|
||
AssertCounter(this); | ||
} | ||
|
||
public void PostponeCounterException(Exception exception) => _postponedCounterExceptions.Add(exception); | ||
|
||
private void ProbeCaptureCompleted(ICounterProbe probe) => | ||
probe.Dump().ForEach(_testOutputHelper.WriteLine); | ||
} |
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,86 @@ | ||||||||
using Lombiq.Tests.UI.Services.Counters.Data; | ||||||||
using System; | ||||||||
using System.Collections.Generic; | ||||||||
using System.Linq; | ||||||||
|
||||||||
namespace Lombiq.Tests.UI.Services.Counters.Configuration; | ||||||||
|
||||||||
public class CounterConfiguration | ||||||||
{ | ||||||||
private const string WorkflowTypeStartActivitiesQuery = | ||||||||
"SELECT DISTINCT [Document].* FROM [Document] INNER JOIN [WorkflowTypeStartActivitiesIndex]" | ||||||||
+ " AS [WorkflowTypeStartActivitiesIndex_a1]" | ||||||||
+ " ON [WorkflowTypeStartActivitiesIndex_a1].[DocumentId] = [Document].[Id]" | ||||||||
+ " WHERE (([WorkflowTypeStartActivitiesIndex_a1].[StartActivityName] = @p0)" | ||||||||
+ " and ([WorkflowTypeStartActivitiesIndex_a1].[IsEnabled] = @p1))"; | ||||||||
|
||||||||
/// <summary> | ||||||||
/// Gets or sets the counter assertion method. | ||||||||
/// </summary> | ||||||||
public Action<ICounterDataCollector, ICounterProbe> AssertCounterData { get; set; } | ||||||||
|
||||||||
/// <summary> | ||||||||
/// Gets or sets the exclude filter. Can be used to exclude counted values before assertion. | ||||||||
/// </summary> | ||||||||
public Func<ICounterKey, bool> ExcludeFilter { get; set; } = DefaultExcludeFilter; | ||||||||
|
||||||||
/// <summary> | ||||||||
/// Gets or sets threshold configuration used under navigation requests. See: | ||||||||
/// <see cref="UI.Extensions.NavigationUITestContextExtensions.GoToAbsoluteUrlAsync(UITestContext, Uri, bool)"/>. | ||||||||
/// See: <see cref="NavigationProbe"/>. | ||||||||
/// </summary> | ||||||||
public CounterThresholdConfiguration NavigationThreshold { get; set; } = new CounterThresholdConfiguration | ||||||||
{ | ||||||||
DbCommandIncludingParametersExecutionCountThreshold = 11, | ||||||||
DbCommandExcludingParametersExecutionThreshold = 22, | ||||||||
DbReaderReadThreshold = 11, | ||||||||
}; | ||||||||
|
||||||||
/// <summary> | ||||||||
/// Gets or sets threshold configuration used per <see cref="YesSql.ISession"/> lifetime. See: | ||||||||
/// <see cref="SessionProbe"/>. | ||||||||
/// </summary> | ||||||||
public CounterThresholdConfiguration SessionThreshold { get; set; } = new CounterThresholdConfiguration | ||||||||
{ | ||||||||
DbCommandIncludingParametersExecutionCountThreshold = 22, | ||||||||
DbCommandExcludingParametersExecutionThreshold = 44, | ||||||||
DbReaderReadThreshold = 11, | ||||||||
}; | ||||||||
|
||||||||
/// <summary> | ||||||||
/// Gets or sets threshold configuration used per page load. See: <see cref="PageLoadProbe"/>. | ||||||||
/// </summary> | ||||||||
public CounterThresholdConfiguration PageLoadThreshold { get; set; } = new CounterThresholdConfiguration | ||||||||
{ | ||||||||
DbCommandIncludingParametersExecutionCountThreshold = 22, | ||||||||
DbCommandExcludingParametersExecutionThreshold = 44, | ||||||||
DbReaderReadThreshold = 11, | ||||||||
}; | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add a piece of docs contrasting these three configs: when would you want to use each? |
||||||||
|
||||||||
public static IEnumerable<ICounterKey> DefaultExcludeList { get; } = new List<ICounterKey> | ||||||||
{ | ||||||||
new DbCommandExecuteCounterKey( | ||||||||
WorkflowTypeStartActivitiesQuery, | ||||||||
new List<KeyValuePair<string, object>> | ||||||||
{ | ||||||||
new("p0", "ContentCreatedEvent"), | ||||||||
new("p1", value: true), | ||||||||
}), | ||||||||
new DbCommandExecuteCounterKey( | ||||||||
WorkflowTypeStartActivitiesQuery, | ||||||||
new List<KeyValuePair<string, object>> | ||||||||
{ | ||||||||
new("p0", "ContentPublishedEvent"), | ||||||||
new("p1", value: true), | ||||||||
}), | ||||||||
new DbCommandExecuteCounterKey( | ||||||||
WorkflowTypeStartActivitiesQuery, | ||||||||
new List<KeyValuePair<string, object>> | ||||||||
{ | ||||||||
new("p0", "ContentUpdatedEvent"), | ||||||||
new("p1", value: true), | ||||||||
}), | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are these excluded? |
||||||||
}; | ||||||||
|
||||||||
public static bool DefaultExcludeFilter(ICounterKey key) => DefaultExcludeList.Contains(key); | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider optimizing the - public static bool DefaultExcludeFilter(ICounterKey key) => DefaultExcludeList.Contains(key);
+ public static bool DefaultExcludeFilter(ICounterKey key) => DefaultExcludeListHashSet.Contains(key);
+ private static HashSet<ICounterKey> DefaultExcludeListHashSet = new HashSet<ICounterKey>(DefaultExcludeList); Committable suggestion
Suggested change
|
||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are 26 duplicated queries during this test?