Skip to content

Commit af10259

Browse files
committed
testkit: add common expectations
1 parent 41a3a19 commit af10259

File tree

9 files changed

+1072
-3
lines changed

9 files changed

+1072
-3
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,11 @@ jobs:
9191

9292
- name: Make target directories
9393
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
94-
run: mkdir -p sdk-exporter/all/.jvm/target sdk-exporter/metrics/.jvm/target sdk-exporter/trace/.jvm/target unidocs/target sdk-contrib/aws/resource/.jvm/target sdk/trace-testkit/.jvm/target sdk-exporter/prometheus/.jvm/target sdk/logs-testkit/.jvm/target sdk-exporter/proto/.jvm/target sdk-exporter/logs/.jvm/target sdk/common/jvm/target sdk/metrics/.jvm/target sdk-exporter/common/.jvm/target sdk/metrics-testkit/.jvm/target sdk/testkit/.jvm/target sdk/logs/.jvm/target sdk/all/.jvm/target sdk-contrib/metrics/jvm/target sdk/trace/.jvm/target sdk-contrib/aws/xray-propagator/.jvm/target sdk-contrib/aws/xray/.jvm/target project/target
94+
run: mkdir -p sdk/common-testkit/.jvm/target sdk-exporter/all/.jvm/target sdk-exporter/metrics/.jvm/target sdk-exporter/trace/.jvm/target unidocs/target sdk-contrib/aws/resource/.jvm/target sdk/trace-testkit/.jvm/target sdk-exporter/prometheus/.jvm/target sdk/logs-testkit/.jvm/target sdk-exporter/proto/.jvm/target sdk-exporter/logs/.jvm/target sdk/common/jvm/target sdk/metrics/.jvm/target sdk-exporter/common/.jvm/target sdk/metrics-testkit/.jvm/target sdk/testkit/.jvm/target sdk/logs/.jvm/target sdk/all/.jvm/target sdk-contrib/metrics/jvm/target sdk/trace/.jvm/target sdk-contrib/aws/xray-propagator/.jvm/target sdk-contrib/aws/xray/.jvm/target project/target
9595

9696
- name: Compress target directories
9797
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
98-
run: tar cf targets.tar sdk-exporter/all/.jvm/target sdk-exporter/metrics/.jvm/target sdk-exporter/trace/.jvm/target unidocs/target sdk-contrib/aws/resource/.jvm/target sdk/trace-testkit/.jvm/target sdk-exporter/prometheus/.jvm/target sdk/logs-testkit/.jvm/target sdk-exporter/proto/.jvm/target sdk-exporter/logs/.jvm/target sdk/common/jvm/target sdk/metrics/.jvm/target sdk-exporter/common/.jvm/target sdk/metrics-testkit/.jvm/target sdk/testkit/.jvm/target sdk/logs/.jvm/target sdk/all/.jvm/target sdk-contrib/metrics/jvm/target sdk/trace/.jvm/target sdk-contrib/aws/xray-propagator/.jvm/target sdk-contrib/aws/xray/.jvm/target project/target
98+
run: tar cf targets.tar sdk/common-testkit/.jvm/target sdk-exporter/all/.jvm/target sdk-exporter/metrics/.jvm/target sdk-exporter/trace/.jvm/target unidocs/target sdk-contrib/aws/resource/.jvm/target sdk/trace-testkit/.jvm/target sdk-exporter/prometheus/.jvm/target sdk/logs-testkit/.jvm/target sdk-exporter/proto/.jvm/target sdk-exporter/logs/.jvm/target sdk/common/jvm/target sdk/metrics/.jvm/target sdk-exporter/common/.jvm/target sdk/metrics-testkit/.jvm/target sdk/testkit/.jvm/target sdk/logs/.jvm/target sdk/all/.jvm/target sdk-contrib/metrics/jvm/target sdk/trace/.jvm/target sdk-contrib/aws/xray-propagator/.jvm/target sdk-contrib/aws/xray/.jvm/target project/target
9999

100100
- name: Upload target directories
101101
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')

build.sbt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ lazy val munitDependencies = Def.settings(
117117
lazy val root = tlCrossRootProject
118118
.aggregate(
119119
`sdk-common`,
120+
`sdk-common-testkit`,
120121
`sdk-logs`,
121122
`sdk-logs-testkit`,
122123
`sdk-metrics`,
@@ -173,6 +174,18 @@ lazy val `sdk-common` = crossProject(JVMPlatform, JSPlatform, NativePlatform)
173174
.settings(munitDependencies)
174175
.jsSettings(scalaJSLinkerSettings)
175176

177+
lazy val `sdk-common-testkit` = crossProject(JVMPlatform, JSPlatform, NativePlatform)
178+
.crossType(CrossType.Pure)
179+
.in(file("sdk/common-testkit"))
180+
.dependsOn(`sdk-common`)
181+
.settings(artifactUploadSettings)
182+
.settings(
183+
name := "otel4s-sdk-common-testkit",
184+
startYear := Some(2026)
185+
)
186+
.settings(munitDependencies)
187+
.jsSettings(scalaJSLinkerSettings)
188+
176189
lazy val `sdk-logs` = crossProject(JVMPlatform, JSPlatform, NativePlatform)
177190
.crossType(CrossType.Pure)
178191
.in(file("sdk/logs"))
@@ -235,12 +248,14 @@ lazy val `sdk-metrics-testkit` =
235248
crossProject(JVMPlatform, JSPlatform, NativePlatform)
236249
.crossType(CrossType.Pure)
237250
.in(file("sdk/metrics-testkit"))
238-
.dependsOn(`sdk-metrics`)
251+
.dependsOn(`sdk-common-testkit`, `sdk-metrics`)
239252
.settings(artifactUploadSettings)
240253
.settings(
241254
name := "otel4s-sdk-metrics-testkit",
242255
startYear := Some(2024)
243256
)
257+
.settings(munitDependencies)
258+
.jsSettings(scalaJSLinkerSettings)
244259

245260
lazy val `sdk-trace` = crossProject(JVMPlatform, JSPlatform, NativePlatform)
246261
.crossType(CrossType.Pure)
@@ -702,6 +717,7 @@ lazy val unidocs = project
702717
name := "otel4s-sdk-docs",
703718
ScalaUnidoc / unidoc / unidocProjectFilter := inProjects(
704719
`sdk-common`.jvm,
720+
`sdk-common-testkit`.jvm,
705721
`sdk-logs`.jvm,
706722
`sdk-logs-testkit`.jvm,
707723
`sdk-metrics`.jvm,
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/*
2+
* Copyright 2026 Typelevel
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.typelevel.otel4s.sdk.testkit
18+
19+
import cats.data.NonEmptyList
20+
import cats.syntax.show._
21+
import org.typelevel.otel4s.Attribute
22+
import org.typelevel.otel4s.Attributes
23+
24+
/** A partial expectation for [[Attributes]].
25+
*
26+
* Use [[AttributesExpectation.exact]] to require the full attribute set to match or [[AttributesExpectation.subset]]
27+
* to require only a subset.
28+
*/
29+
sealed trait AttributesExpectation {
30+
31+
/** Checks the given attributes and returns structured failures when the expectation does not match. */
32+
def check(attributes: Attributes): Either[NonEmptyList[AttributesExpectation.Mismatch], Unit]
33+
34+
/** Returns `true` if this expectation matches the given attributes. */
35+
final def matches(attributes: Attributes): Boolean =
36+
check(attributes).isRight
37+
}
38+
39+
object AttributesExpectation {
40+
41+
/** A structured reason explaining why an [[AttributesExpectation]] did not match actual attributes. */
42+
sealed trait Mismatch extends Product with Serializable {
43+
44+
/** A human-readable description of the mismatch. */
45+
def message: String
46+
}
47+
48+
object Mismatch {
49+
50+
/** Indicates that an expected attribute was missing. */
51+
sealed trait MissingAttribute extends Mismatch {
52+
53+
/** The missing attribute. */
54+
def attribute: Attribute[_]
55+
}
56+
57+
/** Indicates that an attribute was present unexpectedly. */
58+
sealed trait UnexpectedAttribute extends Mismatch {
59+
60+
/** The unexpected attribute. */
61+
def attribute: Attribute[_]
62+
}
63+
64+
/** Indicates that an attribute key was present, but its value differed from the expected one. */
65+
sealed trait AttributeValueMismatch extends Mismatch {
66+
67+
/** The expected attribute. */
68+
def expected: Attribute[_]
69+
70+
/** The actual attribute. */
71+
def actual: Attribute[_]
72+
}
73+
74+
/** Indicates that a custom predicate expectation returned `false`. */
75+
sealed trait PredicateFailed extends Mismatch {
76+
77+
/** An optional clue attached to the predicate. */
78+
def clue: Option[String]
79+
}
80+
81+
/** Creates a mismatch for a missing attribute. */
82+
def missingAttribute(attribute: Attribute[_]): MissingAttribute =
83+
MissingAttributeImpl(attribute)
84+
85+
/** Creates a mismatch for an unexpected attribute. */
86+
def unexpectedAttribute(attribute: Attribute[_]): UnexpectedAttribute =
87+
UnexpectedAttributeImpl(attribute)
88+
89+
/** Creates a mismatch for an attribute whose value differed. */
90+
def attributeValueMismatch(expected: Attribute[_], actual: Attribute[_]): AttributeValueMismatch =
91+
AttributeValueMismatchImpl(expected, actual)
92+
93+
/** Creates a mismatch for a failed custom predicate. */
94+
def predicateFailed(clue: Option[String]): PredicateFailed =
95+
PredicateFailedImpl(clue)
96+
97+
private final case class MissingAttributeImpl(attribute: Attribute[_]) extends MissingAttribute {
98+
def message: String =
99+
show"missing attribute $attribute"
100+
}
101+
102+
private final case class UnexpectedAttributeImpl(attribute: Attribute[_]) extends UnexpectedAttribute {
103+
def message: String =
104+
show"unexpected attribute $attribute"
105+
}
106+
107+
private final case class AttributeValueMismatchImpl(expected: Attribute[_], actual: Attribute[_])
108+
extends AttributeValueMismatch {
109+
def message: String =
110+
show"attribute mismatch for '${expected.key.name}': expected $expected, got $actual"
111+
}
112+
113+
private final case class PredicateFailedImpl(clue: Option[String]) extends PredicateFailed {
114+
def message: String =
115+
s"attributes predicate returned false${clue.fold("")(value => s": $value")}"
116+
}
117+
}
118+
119+
/** Creates an expectation that matches only when all attributes are equal. */
120+
def exact(attributes: Attributes): AttributesExpectation =
121+
Exact(attributes)
122+
123+
/** Creates an expectation that matches when all expected attributes are present in the actual set. */
124+
def subset(attributes: Attributes): AttributesExpectation =
125+
Subset(attributes)
126+
127+
/** Creates an expectation that matches only an empty attribute set. */
128+
def empty: AttributesExpectation =
129+
exact(Attributes.empty)
130+
131+
/** Creates an expectation from a custom predicate. */
132+
def where(f: Attributes => Boolean): AttributesExpectation =
133+
Predicate(f, None)
134+
135+
/** Creates an expectation from a custom predicate with an optional clue used in mismatch messages. */
136+
def where(clue: String)(f: Attributes => Boolean): AttributesExpectation =
137+
Predicate(f, Some(clue))
138+
139+
private final case class Exact(expected: Attributes) extends AttributesExpectation {
140+
def check(attributes: Attributes): Either[NonEmptyList[Mismatch], Unit] = {
141+
val missingOrMismatched = expected.map { attribute =>
142+
attributes.get(attribute.key) match {
143+
case Some(actual) if actual == attribute =>
144+
ExpectationChecks.success
145+
case Some(actual) =>
146+
ExpectationChecks.mismatch(Mismatch.attributeValueMismatch(attribute, actual))
147+
case None =>
148+
ExpectationChecks.mismatch(Mismatch.missingAttribute(attribute))
149+
}
150+
}
151+
152+
val unexpected = attributes.collect {
153+
case attribute if expected.get(attribute.key).isEmpty =>
154+
Left(NonEmptyList.one(Mismatch.unexpectedAttribute(attribute)))
155+
}
156+
157+
ExpectationChecks.combine((missingOrMismatched ++ unexpected).toList)
158+
}
159+
}
160+
161+
private final case class Subset(expected: Attributes) extends AttributesExpectation {
162+
def check(attributes: Attributes): Either[NonEmptyList[Mismatch], Unit] =
163+
ExpectationChecks.combine(expected.map { attribute =>
164+
attributes.get(attribute.key) match {
165+
case Some(actual) if actual == attribute =>
166+
ExpectationChecks.success
167+
case Some(actual) =>
168+
ExpectationChecks.mismatch(Mismatch.attributeValueMismatch(attribute, actual))
169+
case None =>
170+
ExpectationChecks.mismatch(Mismatch.missingAttribute(attribute))
171+
}
172+
}.toList)
173+
}
174+
175+
private final case class Predicate(
176+
f: Attributes => Boolean,
177+
clue: Option[String]
178+
) extends AttributesExpectation {
179+
def check(attributes: Attributes): Either[NonEmptyList[Mismatch], Unit] =
180+
Either.cond(f(attributes), (), NonEmptyList.one(Mismatch.predicateFailed(clue)))
181+
}
182+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright 2026 Typelevel
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.typelevel.otel4s.sdk.testkit
18+
19+
import cats.data.NonEmptyList
20+
21+
private[testkit] object ExpectationChecks {
22+
23+
def success[A]: Either[NonEmptyList[A], Unit] =
24+
Right(())
25+
26+
def mismatch[A](failure: A): Either[NonEmptyList[A], Unit] =
27+
Left(NonEmptyList.one(failure))
28+
29+
def combine[A](results: Iterable[Either[NonEmptyList[A], Unit]]): Either[NonEmptyList[A], Unit] = {
30+
val failures = results.collect { case Left(nel) => nel }.toList
31+
failures match {
32+
case Nil => Right(())
33+
case head :: tail => Left(tail.foldLeft(head)(_.concatNel(_)))
34+
}
35+
}
36+
37+
def combine[A](results: Either[NonEmptyList[A], Unit]*): Either[NonEmptyList[A], Unit] =
38+
combine(results.toList)
39+
40+
def nested[Inner, Outer](result: Either[NonEmptyList[Inner], Unit])(
41+
wrap: NonEmptyList[Inner] => Outer
42+
): Either[NonEmptyList[Outer], Unit] =
43+
result.left.map(failures => NonEmptyList.one(wrap(failures)))
44+
45+
def compareOption[A](
46+
expected: Option[Option[String]],
47+
actual: Option[String]
48+
)(mismatch: (Option[String], Option[String]) => A): Either[NonEmptyList[A], Unit] =
49+
expected match {
50+
case None =>
51+
Right(())
52+
case Some(Some(value)) if actual.contains(value) =>
53+
Right(())
54+
case Some(Some(value)) =>
55+
Left(NonEmptyList.one(mismatch(Some(value), actual)))
56+
case Some(None) =>
57+
Either.cond(actual.isEmpty, (), NonEmptyList.one(mismatch(None, actual)))
58+
}
59+
}

0 commit comments

Comments
 (0)