Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8df8cad
chore(tests): added cross source tests
ivankatliarchuk Jan 31, 2026
ec8f6da
chore(tests): added cross source tests
ivankatliarchuk Jan 31, 2026
babd0b7
Merge branch 'master' into chore-integration-tests-v0
ivankatliarchuk Feb 1, 2026
e02ebe8
chore(tests): added cross source tests
ivankatliarchuk Feb 1, 2026
d79eae2
Merge branch 'kubernetes-sigs:master' into chore-integration-tests-v0
ivankatliarchuk Feb 21, 2026
1cee31b
chore(tests): added cross source tests
ivankatliarchuk Feb 21, 2026
fd05e7c
chore(tests): Add YAML-driven integration test framework for sources
ivankatliarchuk Feb 22, 2026
bb4fdc9
Merge branch 'kubernetes-sigs:master' into chore-integration-tests-v0
ivankatliarchuk Mar 1, 2026
a53070d
Merge branch 'kubernetes-sigs:master' into chore-integration-tests-v0
ivankatliarchuk Mar 12, 2026
3c2e875
Merge branch 'kubernetes-sigs:master' into chore-integration-tests-v0
ivankatliarchuk Mar 14, 2026
905c6db
Merge branch 'kubernetes-sigs:master' into chore-integration-tests-v0
ivankatliarchuk Mar 15, 2026
a10c282
chore(tests): Add YAML-driven integration test framework for sources
ivankatliarchuk Mar 15, 2026
6ebc0d2
Merge branch 'kubernetes-sigs:master' into chore-integration-tests-v0
ivankatliarchuk Mar 15, 2026
9c9182c
Merge branch 'kubernetes-sigs:master' into chore-integration-tests-v0
ivankatliarchuk Mar 16, 2026
9acd6aa
chore(tests): Add YAML-driven integration test framework for sources
ivankatliarchuk Mar 16, 2026
601390f
chore(tests): Add YAML-driven integration test framework for sources
ivankatliarchuk Mar 16, 2026
f73a535
Merge branch 'kubernetes-sigs:master' into chore-integration-tests-v0
ivankatliarchuk Mar 16, 2026
42c0c0e
chore(tests): Add YAML-driven integration test framework for sources
ivankatliarchuk Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions docs/contributing/dev-guide.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
---
tags:
- contributing
- build
- testing
- integration-tests
- helm
---

# Developer Reference

The `external-dns` is the work of thousands of contributors, and is maintained by a small team within [kubernetes-sigs](https://github.com/kubernetes-sigs). This document covers basic needs to work with `external-dns` codebase. It contains instructions to build, run, and test `external-dns`.
Expand Down Expand Up @@ -78,6 +87,99 @@ func TestMe(t *testing.T) {
}
```

### Integration Tests

Integration tests live in `tests/integration/` and verify behavior that spans multiple sources or wrappers together, using a fake Kubernetes client — no real cluster is required.

#### Where integration tests sit

```mermaid
flowchart TD
E2E["E2E Tests<br>Real cluster + real DNS provider<br>Slow · requires cloud credentials"]
IT["Integration Tests ← tests/integration/<br>Fake Kubernetes API · no cluster needed<br>Tests source + wrapper combinations · fast<br>Declarative YAML scenarios"]
UT["Unit Tests<br>One source or wrapper in isolation<br>Mocked or minimal Kubernetes client"]

E2E --> IT --> UT

style IT fill:#bbf7d0,stroke:#15803d,stroke-width:2px
```

#### What runs during a test

```mermaid
flowchart LR
subgraph yaml["tests/integration/scenarios/tests.yaml"]
RES["resources<br>Service · Ingress · Pod"]
CFG["config<br>sources · filters · wrappers"]
EXP["expected<br>endpoints"]
end

subgraph toolkit["toolkit — fake Kubernetes"]
PARSE["ParseResources()"]
FAKE["fake.Clientset"]
WRAP["CreateWrappedSource()"]
end

subgraph pipeline["ExternalDNS pipeline under test"]
SRC["Source(s)<br>service · ingress · ..."]
WRP["Wrapper(s)<br>dedup · targetFilter · NAT64"]
OUT["Endpoints"]
end

ASSERT["ValidateEndpoints()<br>DNSName · Targets<br>RecordType · TTL"]

RES --> PARSE --> FAKE --> WRAP
CFG --> WRAP
WRAP --> SRC --> WRP --> OUT --> ASSERT
EXP --> ASSERT
```

**When to add an integration test:**

- You are adding or changing a **source** (e.g. `service`, `ingress`) and want to verify it produces the correct endpoints end-to-end.
- You are changing a **wrapper** (e.g. deduplication, target filtering, default targets, NAT64) and want to verify it behaves correctly when real Kubernetes resources are involved.
- You are changing a **post-processor** and want to confirm it applies correctly to endpoints produced by one or more sources.
- You are verifying **multiple sources** together (e.g. `service` and `ingress` both pointing to the same hostname) and their combined output.
- You are fixing a **cross-cutting bug** that only manifests when sources, wrappers, and post-processors interact.
- A unit test would require mocking too many internals — an integration test can express the scenario more clearly as a real Kubernetes resource.

**How to add a scenario:**

Add an entry to `tests/integration/scenarios/tests.yaml`. Each scenario declares Kubernetes resources (Service, Ingress, etc.), the ExternalDNS source configuration, and the expected endpoints:

```yaml
- name: my-new-scenario
description: >
Brief explanation of what behavior this scenario validates.
config:
sources: ["service"]
resources:
- resource:
apiVersion: v1
kind: Service
metadata:
name: my-svc
namespace: default
annotations:
external-dns.alpha.kubernetes.io/hostname: my.example.com
spec:
type: LoadBalancer
status:
loadBalancer:
ingress:
- ip: 1.2.3.4
expected:
- dnsName: my.example.com
targets: ["1.2.3.4"]
recordType: A
```

**How to run:**

```shell
go test ./tests/integration/...
```

## Complete test on local env

It's possible to run ExternalDNS locally. CoreDNS can be used for easier testing.
Expand Down
4 changes: 4 additions & 0 deletions tests/integration/OWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# See the OWNERS docs at https://go.k8s.io/owners

labels:
- tests-integration
233 changes: 233 additions & 0 deletions tests/integration/scenarios/tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
# Integration Test Scenarios
#
# Schema Summary:
# | Field | Type | Description |
# |----------------------------------------|----------|------------------------------------------|
# | name | string | Test scenario name |
# | config.sources | []string | Sources to create: ingress, service |
# | config.defaultTargets | []string | --default-targets flag values |
# | config.forceDefaultTargets | bool | --force-default-targets flag |
# | config.targetNetFilter | []string | --target-net-filter flag values |
# | config.serviceTypeFilter | []string | --service-type-filter flag values |
# | resources | []object | K8s resources with optional dependencies |
# | resources[].resource | object | K8s resource (Ingress, Service, etc.) |
# | resources[].dependencies | object | Auto-generated dependent resources |
# | resources[].dependencies.pods.replicas | int | Number of pods to generate |
# | expected | []object | Expected endpoints |

# TODO:
# 1. Support to Endpoint.ResourceLabelKey
# 2. Support for Endpoint.RefObject
# 3. Support for Endpoint.ProviderSpecific

scenarios:
- name: headless-service-with-pods
description: >
Test that a headless Service with associated Pods
creates the correct DNS A records for each Pod IP.
config:
sources: ["service"]
targetNetFilter: ["10.0.0.1/32", "10.0.0.2/32"]
serviceTypeFilter: ["ClusterIP"]
resources:
- resource:
apiVersion: v1
kind: Service
metadata:
name: headless-svc
namespace: default
labels:
app: myapp
annotations:
external-dns.alpha.kubernetes.io/hostname: headless.example.com
spec:
type: ClusterIP
clusterIP: None
selector:
app: myapp
dependencies:
pods:
replicas: 3
expected:
- dnsName: headless.example.com
targets: ["10.0.0.1", "10.0.0.2"]
recordType: A
- dnsName: headless-svc-0.headless.example.com
targets: ["10.0.0.1"]
recordType: A
- dnsName: headless-svc-1.headless.example.com
targets: ["10.0.0.2"]
recordType: A

- name: service-loadbalancer-with-ip
description: >
Test that a Service of type LoadBalancer with an assigned IP
creates the correct DNS A record.
config:
sources: ["service"]
resources:
- resource:
apiVersion: v1
kind: Service
metadata:
name: test-service
namespace: default
annotations:
external-dns.alpha.kubernetes.io/hostname: svc.example.com
spec:
selector:
app.kubernetes.io/name: MyApp
type: LoadBalancer
status:
loadBalancer:
ingress:
- ip: 1.2.3.4
expected:
- dnsName: svc.example.com
targets: ["1.2.3.4"]
recordType: A

- name: known-limitation-cross-source-dedup-not-applied
description: >
Documents a known limitation: when multiple ingresses share the same hostname,
ingressSource.Endpoints() merges their targets via MergeEndpoints() before the
dedupSource ever sees them. The resulting combined ingress endpoint
(["1.2.3.4","203.0.113.10"]) has different Targets.String() than the service
endpoint (["1.2.3.4"]), so the dedupSource keeps both — producing two A records
for the same hostname. Ideally the service endpoint would be absorbed into the
ingress one, but that would require cross-source target merging which does not
exist today. See the "service-and-ingress-same-hostname-and-ip-dedup" scenario
for a case where deduplication does work correctly.
config:
sources: ["service", "ingress"]
resources:
- resource:
apiVersion: v1
kind: Service
metadata:
name: test-service
namespace: default
annotations:
external-dns.alpha.kubernetes.io/hostname: example.local
spec:
selector:
app.kubernetes.io/name: MyApp
type: LoadBalancer
status:
loadBalancer:
ingress:
- ip: 1.2.3.4
- resource:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: test-ingress
namespace: default
annotations:
kubernetes.io/ingress.class: "nginx"
spec:
rules:
- host: example.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service
port:
number: 80
status:
loadBalancer:
ingress:
- ip: 203.0.113.10
- resource:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-with-same-host-and-status-as-service
namespace: kube-system
annotations:
kubernetes.io/ingress.class: "nginx"
spec:
rules:
- host: example.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service
port:
number: 80
status:
loadBalancer:
ingress:
- ip: 1.2.3.4
expected:
# The ingress source merges all ingresses with the same hostname via MergeEndpoints(),
# so both ingresses (203.0.113.10 and 1.2.3.4) produce one combined endpoint.
- dnsName: example.local
targets: ["1.2.3.4", "203.0.113.10"]
recordType: A
# The service source produces a separate endpoint for the same hostname.
- dnsName: example.local
targets: ["1.2.3.4"]
recordType: A

- name: service-and-ingress-same-hostname-and-ip-dedup
description: >
Test that dedupSource correctly removes an exact duplicate endpoint.
Both the Service and the Ingress resolve example.local to the same IP
(1.2.3.4), so each source emits an identical endpoint
(RecordType=A, DNSName=example.local, Targets=["1.2.3.4"]).
The dedupSource key is RecordType+DNSName+SetIdentifier+Targets.String(),
which matches, so the second endpoint is dropped and only one A record survives.
config:
sources: ["service", "ingress"]
resources:
- resource:
apiVersion: v1
kind: Service
metadata:
name: test-service
namespace: default
annotations:
external-dns.alpha.kubernetes.io/hostname: example.local
spec:
selector:
app.kubernetes.io/name: MyApp
type: LoadBalancer
status:
loadBalancer:
ingress:
- ip: 1.2.3.4
- resource:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: test-ingress
namespace: default
annotations:
kubernetes.io/ingress.class: "nginx"
spec:
rules:
- host: example.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service
port:
number: 80
status:
loadBalancer:
ingress:
- ip: 1.2.3.4
expected:
- dnsName: example.local
targets: ["1.2.3.4"]
recordType: A
Loading
Loading