diff --git a/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/NiaIssueRegistry.kt b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/NiaIssueRegistry.kt index 333462770..f402b4039 100644 --- a/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/NiaIssueRegistry.kt +++ b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/NiaIssueRegistry.kt @@ -25,6 +25,9 @@ class NiaIssueRegistry : IssueRegistry() { override val issues = listOf( DesignSystemDetector.ISSUE, + TestMethodDetector.UNDERSCORE, + TestMethodDetector.FORMAT, + TestMethodDetector.PREFIX, ) override val api: Int = CURRENT_API diff --git a/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodDetector.kt b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodDetector.kt new file mode 100644 index 000000000..c7989542e --- /dev/null +++ b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodDetector.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.lint + +import com.android.tools.lint.detector.api.AnnotationInfo +import com.android.tools.lint.detector.api.AnnotationUsageInfo +import com.android.tools.lint.detector.api.Category.Companion.TESTING +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.LintFix +import com.android.tools.lint.detector.api.Scope.JAVA_FILE +import com.android.tools.lint.detector.api.Scope.TEST_SOURCES +import com.android.tools.lint.detector.api.Severity.WARNING +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.android.tools.lint.detector.api.TextFormat.RAW +import com.intellij.psi.PsiMethod +import org.jetbrains.uast.UElement +import java.util.EnumSet +import kotlin.io.path.Path + +/** + * A detector that checks for common patterns in naming the test methods: + * - [detectPrefix] removes unnecessary "test" prefix in all unit test. + * - [detectUnderscore] removes underscores in JVM unit test (and add backticks if necessary). + * - [detectFormat] Checks the `given_when_then` format of Android instrumented tests (backticks are not supported). + */ +class TestMethodDetector : Detector(), SourceCodeScanner { + + override fun applicableAnnotations() = listOf("org.junit.Test") + + override fun visitAnnotationUsage( + context: JavaContext, + element: UElement, + annotationInfo: AnnotationInfo, + usageInfo: AnnotationUsageInfo, + ) { + val method = usageInfo.referenced as? PsiMethod ?: return + + method.detectPrefix(context, usageInfo) + method.detectFormat(context, usageInfo) + method.detectUnderscore(context, usageInfo) + } + + private fun JavaContext.isAndroidTest() = Path("androidTest") in file.toPath() + + private fun PsiMethod.detectPrefix( + context: JavaContext, + usageInfo: AnnotationUsageInfo, + ) { + if (!name.startsWith("test")) return + context.report( + issue = PREFIX, + scope = usageInfo.usage, + location = context.getNameLocation(this), + message = PREFIX.getBriefDescription(RAW), + quickfixData = LintFix.create() + .name("Remove prefix") + .replace().pattern("""test[\s_]*""") + .with("") + .autoFix() + .build(), + ) + } + + private fun PsiMethod.detectFormat( + context: JavaContext, + usageInfo: AnnotationUsageInfo, + ) { + if (!context.isAndroidTest()) return + if ("""^[^\W_]+_[^\W_]+_[^\W_]+$""".toRegex().matches(name)) return + context.report( + issue = FORMAT, + scope = usageInfo.usage, + location = context.getNameLocation(this), + message = FORMAT.getBriefDescription(RAW), + ) + } + + private fun PsiMethod.detectUnderscore( + context: JavaContext, + usageInfo: AnnotationUsageInfo, + ) { + if (context.isAndroidTest()) return + if ("_" !in name) return + context.report( + issue = UNDERSCORE, + scope = usageInfo.usage, + location = context.getNameLocation(this), + message = UNDERSCORE.getBriefDescription(RAW), + quickfixData = LintFix.create() + .name("Replace underscores with spaces") + .replace() + .range(context.getNameLocation(this)) + .with( + name.replace("_", " ") + .removeSurrounding("`") + .let { """`$it`""" }, + ) + .autoFix() + .build(), + ) + } + + companion object { + + private fun issue( + id: String, + briefDescription: String, + explanation: String, + ): Issue = Issue.create( + id = id, + briefDescription = briefDescription, + explanation = explanation, + category = TESTING, + priority = 5, + severity = WARNING, + implementation = Implementation( + TestMethodDetector::class.java, + EnumSet.of(JAVA_FILE, TEST_SOURCES), + ), + ) + + @JvmField + val UNDERSCORE: Issue = issue( + id = "TestMethodUnderscore", + briefDescription = "Test method contains underscores", + explanation = "Test methods should not contains underscores.", + ) + + @JvmField + val PREFIX: Issue = issue( + id = "TestMethodPrefix", + briefDescription = "Test method starts with `test`", + explanation = "Test method should not start with `test`.", + ) + + @JvmField + val FORMAT: Issue = issue( + id = "TestMethodFormat", + briefDescription = "Test method does not follow the `given_when_then` format", + explanation = "Test method should follow the `given_when_then` format.", + ) + } +} diff --git a/lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodDetectorTest.kt b/lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodDetectorTest.kt new file mode 100644 index 000000000..215efebf8 --- /dev/null +++ b/lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodDetectorTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.lint + +import com.android.tools.lint.checks.infrastructure.TestFile +import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import com.google.samples.apps.nowinandroid.lint.TestMethodDetector.Companion.FORMAT +import com.google.samples.apps.nowinandroid.lint.TestMethodDetector.Companion.PREFIX +import org.junit.Test + +class TestMethodDetectorTest { + + @Test + fun `detect prefix`() { + lint().issues(PREFIX) + .files( + JUNIT_TEST_STUB, + kotlin( + """ + import org.junit.Test + class Test { + @Test + fun foo() = Unit + @Test + fun test_foo() = Unit + @Test + fun `test foo`() = Unit + } + """, + ).indented(), + ) + .run() + .expect( + """ + src/Test.kt:6: Warning: Test method starts with test [TestMethodWithTestPrefix] + fun test_foo() = Unit + ~~~~~~~~ + src/Test.kt:8: Warning: Test method starts with test [TestMethodWithTestPrefix] + fun `test foo`() = Unit + ~~~~~~~~~~ + 0 errors, 2 warnings + """.trimIndent(), + ) + .expectFixDiffs( + """ + Fix for src/Test.kt line 6: Remove underscores: + @@ -6 +6 + - fun test_foo() = Unit + + fun foo() = Unit + Fix for src/Test.kt line 8: Remove underscores: + @@ -8 +8 + - fun `test foo`() = Unit + + fun `foo`() = Unit + """.trimIndent(), + ) + } + + @Test + fun `detect format`() { + lint().issues(FORMAT) + .files( + JUNIT_TEST_STUB, + kotlin( + "src/androidTest/com/example/Test.kt", + """ + import org.junit.Test + class Test { + @Test + fun given_when_then() = Unit + @Test + fun given_foo_when_bar_then_baz() = Unit + } + """, + ).indented(), + ) + .run() + .expect( + """ + src/androidTest/com/example/Test.kt:6: Warning: Test method does not follow the given_when_then format [TestMethodGivenWhenThenFormatTest] + fun given_foo_when_bar_then_baz() = Unit + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 0 errors, 1 warnings + """.trimIndent(), + ) + } + + @Test + fun `detect underscores`() { + lint().issues(UNDERSCORES) + .files( + JUNIT_TEST_STUB, + kotlin( + """ + import org.junit.Test + class Test { + @Test + fun foo() = Unit + @Test + fun foo_bar_baz() = Unit + @Test + fun `foo_bar_baz`() = Unit + } + """, + ).indented(), + ) + .run() + .expect( + """ + src/Test.kt:6: Warning: Test method contains underscores [TestMethodContainsUnderscore] + fun foo_bar_baz() = Unit + ~~~~~~~~~~~ + src/Test.kt:8: Warning: Test method contains underscores [TestMethodContainsUnderscore] + fun `foo_bar_baz`() = Unit + ~~~~~~~~~~~~~ + 0 errors, 2 warnings + """.trimIndent(), + ) + .expectFixDiffs( + """ + Autofix for src/Test.kt line 6: Replace underscores with spaces: + @@ -6 +6 + - fun foo_bar_baz() = Unit + + fun `foo bar baz`() = Unit + Autofix for src/Test.kt line 8: Replace underscores with spaces: + @@ -8 +8 + - fun `foo_bar_baz`() = Unit + + fun `foo bar baz`() = Unit + """.trimIndent(), + ) + } + + private companion object { + private val JUNIT_TEST_STUB: TestFile = kotlin( + """ + package org.junit + annotation class Test + """, + ).indented() + } +}