Skip to content

Commit

Permalink
Merge pull request #20 from buildkite/tech/refine-collector-api-and-d…
Browse files Browse the repository at this point in the history
…ocumentation

Document collector implementation and Enhance API models
  • Loading branch information
thebhumilsoni authored Apr 22, 2024
2 parents ba7d0e5 + 3a27e55 commit dfd4711
Show file tree
Hide file tree
Showing 28 changed files with 467 additions and 190 deletions.
2 changes: 0 additions & 2 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,88 +1,103 @@
package com.buildkite.test.collector.android

import com.buildkite.test.collector.android.models.FailureExpanded
import com.buildkite.test.collector.android.models.Span
import com.buildkite.test.collector.android.models.TestDetails
import com.buildkite.test.collector.android.models.TraceResult
import com.buildkite.test.collector.android.model.TestDetails
import com.buildkite.test.collector.android.model.TestFailureExpanded
import com.buildkite.test.collector.android.model.TestHistory
import com.buildkite.test.collector.android.model.TestOutcome
import com.buildkite.test.collector.android.tracer.TestObserver
import com.buildkite.test.collector.android.tracer.environment.configureInstrumentedTestUploader
import org.junit.runner.Description
import org.junit.runner.notification.Failure
import org.junit.runner.notification.RunListener

/**
* Serves as an abstract foundation for creating instrumented test collectors that interface with Buildkite's Test Analytics service.
* This class extends JUnit's [RunListener] to capture real-time events during the execution of instrumented tests, enabling precise monitoring
* and reporting of test outcomes. It automatically gathers detailed test results and uploads them directly to the analytics portal at the conclusion of test suite.
*
* @param apiToken The API token for the test suite, necessary for authenticating requests with Test Analytics.
* @param isDebugEnabled When true, enables logging to assist with debugging.
*/
abstract class InstrumentedTestCollector(
apiToken: String,
isDebugEnabled: Boolean = false
) : RunListener() {
private val testObserver = TestObserver()
private val testUploader = configureInstrumentedTestUploader(apiToken, isDebugEnabled)
private val testUploader = configureInstrumentedTestUploader(
apiToken = apiToken,
isDebugEnabled = isDebugEnabled
)
private val testCollection: MutableList<TestDetails> = mutableListOf()

override fun testSuiteStarted(testDescription: Description?) { /* Nothing to do */ }
override fun testSuiteStarted(testDescription: Description) {
/* Nothing to do before the test suite has started */
}

override fun testSuiteFinished(description: Description?) {
description?.let { testSuite ->
if ((testSuite.displayName.isNullOrEmpty() || testSuite.displayName == "null") && (testSuite.className.isNullOrEmpty() || testSuite.className == "null")) {
testUploader.configureUploadData(testCollection = testObserver.collection)
}
override fun testSuiteFinished(description: Description) {
if (isFinalTestSuiteCall(testDescription = description)) {
testUploader.configureUploadData(testCollection = testCollection)
}
}

override fun testStarted(testDescription: Description?) {
testObserver.recordStartTime()
override fun testStarted(testDescription: Description) {
testObserver.startTest()
}

override fun testFinished(testDescription: Description?) {
testObserver.recordEndTime()
override fun testFinished(testDescription: Description) {
testObserver.endTest()

if (testObserver.result != TraceResult.Failed) {
testObserver.result = TraceResult.Passed
if (testObserver.outcome != TestOutcome.Failed) {
testObserver.recordSuccess()
}

testDescription?.let { test ->
addTestDetailsToCollection(test = test)
}
addTestDetailsToCollection(test = testDescription)
}

override fun testFailure(failureDetails: Failure?) {
failureDetails?.let { failure ->
testObserver.result = TraceResult.Failed
testObserver.failureReason = failure.exception.toString()
testObserver.failureExpanded = listOf(
FailureExpanded(
expanded = failure.trimmedTrace.split("\n").map { it.trim() },
backtrace = failure.trace.split("\n").map { it.trim() },
)
override fun testFailure(failureDetails: Failure) {
val failureReason = failureDetails.exception.toString()
val details = listOf(
TestFailureExpanded(
expanded = failureDetails.trimmedTrace.split("\n").map { it.trim() },
backtrace = failureDetails.trace.split("\n").map { it.trim() },
)
}
)
testObserver.recordFailure(reason = failureReason, details = details)
}

override fun testIgnored(testDescription: Description?) {
testObserver.result = TraceResult.Skipped
override fun testIgnored(testDescription: Description) {
testObserver.recordSkipped()

testDescription?.let { test ->
addTestDetailsToCollection(test = test)
}
addTestDetailsToCollection(test = testDescription)
}

private fun addTestDetailsToCollection(test: Description) {
val testSpan = Span(
val testHistory = TestHistory(
startAt = testObserver.startTime,
endAt = testObserver.endTime,
duration = testObserver.calculateSpanDuration(),
duration = testObserver.getDuration()
)

val testDetails = TestDetails(
scope = test.testClass.name,
name = test.methodName,
location = test.className,
fileName = null,
result = testObserver.result,
result = testObserver.outcome,
failureReason = testObserver.failureReason,
failureExpanded = testObserver.failureExpanded,
history = testSpan
failureExpanded = testObserver.failureDetails,
history = testHistory
)

testObserver.collection.add(testDetails)
testObserver.resetTestData()
testCollection.add(testDetails)
testObserver.reset()
}

/**
* Determines if the provided test suite descriptor indicates the final call of the test suite,
* which is true when both displayName and className are null.
*
* @param testDescription The test description. [Description] can be atomic (a single test) or compound (containing children tests).
*/
private fun isFinalTestSuiteCall(testDescription: Description) =
(testDescription.displayName.isNullOrEmpty() || testDescription.displayName == "null") && (testDescription.className.isNullOrEmpty() || testDescription.className == "null")
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
package com.buildkite.test.collector.android

import com.buildkite.test.collector.android.models.RunEnvironment
import com.buildkite.test.collector.android.models.TestData
import com.buildkite.test.collector.android.models.TestDetails
import com.buildkite.test.collector.android.models.TestResponse
import com.buildkite.test.collector.android.network.RetrofitInstance
import com.buildkite.test.collector.android.model.RunEnvironment
import com.buildkite.test.collector.android.model.TestData
import com.buildkite.test.collector.android.model.TestDetails
import com.buildkite.test.collector.android.model.TestUploadResponse
import com.buildkite.test.collector.android.network.TestAnalyticsRetrofit
import com.buildkite.test.collector.android.network.api.TestUploaderApi
import com.buildkite.test.collector.android.util.CollectorUtils.Uploader
import com.buildkite.test.collector.android.util.Logger
import retrofit2.Response

/**
* Manages the upload of test data to the Buildkite Test Analytics Suite associated with the provided API token.
*
* @property testSuiteApiToken The API token for the test suite, necessary for authenticating requests with Test Analytics.
* Test data will not be uploaded if this is null.
* @property isDebugEnabled When true, enables logging to assist with debugging.
*/
class TestDataUploader(
private val testSuiteApiToken: String?,
private val isDebugEnabled: Boolean
) {
private val logger =
Logger(minLevel = if (isDebugEnabled) Logger.LogLevel.DEBUG else Logger.LogLevel.INFO)

/**
* Configures and uploads test data.
* The number of test data uploaded in a single request is constrained by [Uploader.TEST_DATA_UPLOAD_LIMIT].
*
* @param testCollection A list of [TestDetails] representing all the tests within the suite.
* */
fun configureUploadData(testCollection: List<TestDetails>) {
val runEnvironment = RunEnvironment().getEnvironmentValues()

Expand All @@ -27,31 +44,35 @@ class TestDataUploader(

private fun uploadTestData(testData: TestData) {
if (testSuiteApiToken == null) {
println(
"Buildkite test suite API token is missing. " +
"Please set up your API token environment variable to upload the analytics data. Follow [README] for further information."
)
} else {
val testUploaderService = RetrofitInstance.getRetrofitInstance(testSuiteApiToken = testSuiteApiToken)
.create(TestUploaderApi::class.java)
val uploadTestDataApiCall = testUploaderService.uploadTestData(testData = testData)
logger.info {
"Test Suite API token is missing. Please ensure the 'BUILDKITE_ANALYTICS_TOKEN' environment variable is set correctly to upload test data."
}
return
}

val executeApiCall = uploadTestDataApiCall.execute()
try {
logger.debug { "Uploading test analytics data." }

if (isDebugEnabled) {
logApiResponse(executeApiCall)
}
val testUploaderService =
TestAnalyticsRetrofit.getRetrofitInstance(testSuiteApiToken = testSuiteApiToken)
.create(TestUploaderApi::class.java)
val uploadTestDataApiCall = testUploaderService.uploadTestData(testData = testData)
val testUploadResponse = uploadTestDataApiCall.execute()

logApiResponse(testUploadResponse = testUploadResponse)
} catch (e: Exception) {
logger.error { "Error uploading test analytics data: ${e.message}." }
}
}

private fun logApiResponse(executeApiCall: Response<TestResponse>) {
when (val apiResponseCode = executeApiCall.raw().code) {
202 -> println(
"\nTest analytics data successfully uploaded to the BuildKite Test Suite. - ${executeApiCall.body()?.runUrl}"
)
else -> println(
"\nError uploading test analytics data to the BuildKite Test Suite. Error code: $apiResponseCode! Ensure that the test suite API Token is correct."
)
private fun logApiResponse(testUploadResponse: Response<TestUploadResponse>) {
if (testUploadResponse.isSuccessful) {
logger.debug { "Test analytics data successfully uploaded. URL: ${testUploadResponse.body()?.runUrl}" }
} else {
logger.error {
"Error uploading test analytics data. HTTP error code: ${testUploadResponse.code()}. Ensure the test suite API token is correct and properly configured."
}
logger.debug { "Failed response details: ${testUploadResponse.errorBody()?.string()}" }
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
package com.buildkite.test.collector.android.models
package com.buildkite.test.collector.android.model

import com.buildkite.test.collector.android.util.CollectorUtils.generateUUIDString
import com.google.gson.annotations.SerializedName

/**
* Represents information about the environment in which the test run is performed. Suitable for identifying test runs across CI and local environments.
*
* @property key A unique identifier for the test run.
* It's the only required field, especially for local tests where CI-specific fields aren't relevant.
* @property ci The continuous integration platform name.
* @property url The URL associated with the test run.
* @property branch The branch or reference for this build.
* @property commitSha The commit hash for the head of the branch.
* @property number The build number.
* @property jobId The job UUID.
* @property message The commit message for the head of the branch.
* @property version The current version of the collector.
* @property collector The current name of the collector.
*/
internal data class RunEnvironment(
@SerializedName("CI") val ci: String? = null,
@SerializedName("key") val key: String = generateUUIDString(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.buildkite.test.collector.android.model

import com.google.gson.annotations.SerializedName

/**
* Represents the payload for Buildkite test analytics API.
*
* @property format Specifies the format for the upload data.
* @property runEnvironment Context of the test execution environment.
* Test results with matching run environments will be grouped together by the analytics API.
* @property data List of [TestDetails] providing individual test outcomes and related information.
*/
internal data class TestData(
@SerializedName("format") val format: String = "json",
@SerializedName("run_env") val runEnvironment: RunEnvironment,
@SerializedName("data") val data: List<TestDetails>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.buildkite.test.collector.android.model

import com.buildkite.test.collector.android.util.CollectorUtils.generateUUIDString
import com.google.gson.annotations.SerializedName

/**
* Represents a single test run with comprehensive details.
*
* @property id A unique identifier for this test result, defaulting to a generated UUID string.
* Duplicate IDs are ignored by the Test Analytics database.
* @property scope Specifies a group or topic for the test.
* @property name The name or description of the test.
* @property location The file and line number where the test originates.
* @property fileName The file where the test is defined.
* @property result The outcome of the test, specified as a [TestOutcome].
* @property failureReason A short description of the failure.
* @property failureExpanded Detailed failure information as a list of [TestFailureExpanded] objects.
* @property history The span information capturing the duration and execution details of the test.
*/
data class TestDetails(
@SerializedName("id") val id: String = generateUUIDString(),
@SerializedName("scope") val scope: String?,
@SerializedName("name") val name: String,
@SerializedName("location") val location: String?,
@SerializedName("file_name") val fileName: String?,
@SerializedName("result") val result: TestOutcome?,
@SerializedName("failure_reason") val failureReason: String?,
@SerializedName("failure_expanded") val failureExpanded: List<TestFailureExpanded>? = null,
@SerializedName("history") val history: TestHistory
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.buildkite.test.collector.android.model

import com.google.gson.annotations.SerializedName

/**
* Represents additional details about a test failure.
*
* @property backtrace A list of strings, each representing a frame in the call stack at the failure point.
* @property expanded A list of strings detailing the failure, including error messages and any supplementary context.
*/
data class TestFailureExpanded(
@SerializedName("backtrace") val backtrace: List<String> = emptyList(),
@SerializedName("expanded") val expanded: List<String> = emptyList()
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.buildkite.test.collector.android.model

import com.google.gson.annotations.SerializedName

/**
* Represents the overall duration and phases of an individual test.
*
* @property startAt The start timestamp of the test.
* @property endAt The end timestamp of the test.
* @property duration The total duration of the test.
* @property children A collection of [TestSpan] objects detailing specific segments or operations within the test.
*/
data class TestHistory(
@SerializedName("start_at") val startAt: Long,
@SerializedName("end_at") val endAt: Long?,
@SerializedName("duration") val duration: Double,
@SerializedName("children") val children: List<TestSpan>? = null,
)
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.buildkite.test.collector.android.models
package com.buildkite.test.collector.android.model

import com.google.gson.annotations.SerializedName

enum class TraceResult {
/**
* Represents the outcome of the test.
* */
enum class TestOutcome {
@SerializedName("passed")
Passed,

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.buildkite.test.collector.android.model

import com.google.gson.annotations.SerializedName

/**
* Represents a specific operation or activity segment within a test, detailing its timing and additional information.
*
* This class captures the finer resolution of a test's execution duration, such as the time taken by an individual database query.
*
* @property section The category of this span, indicating the type of operation (e.g., SQL query, HTTP request). See [TestSpanSection].
* @property startAt The start timestamp of the test span.
* @property endAt The end timestamp of the test span.
* @property duration The total duration of the test span.
* @property detail [TestSpanDetail] object providing additional information about the span, required for certain section types like `http` or `sql`.
*/
data class TestSpan(
@SerializedName("section") val section: TestSpanSection = TestSpanSection.Top,
@SerializedName("start_at") val startAt: Long?,
@SerializedName("end_at") val endAt: Long?,
@SerializedName("duration") val duration: Double,
@SerializedName("detail") val detail: TestSpanDetail? = null
)
Loading

0 comments on commit dfd4711

Please sign in to comment.