Skip to main content

vector/config/unit_test/
mod.rs

1// should match vector-unit-test-tests feature
2#[cfg(all(
3    test,
4    feature = "sources-demo_logs",
5    feature = "transforms-remap",
6    feature = "transforms-route",
7    feature = "transforms-filter",
8    feature = "transforms-reduce",
9    feature = "sinks-console"
10))]
11mod tests;
12mod unit_test_components;
13
14use std::{
15    collections::{BTreeMap, HashMap, HashSet},
16    sync::Arc,
17};
18
19use futures_util::{StreamExt, stream::FuturesUnordered};
20use indexmap::IndexMap;
21use tokio::sync::{
22    Mutex,
23    oneshot::{self, Receiver},
24};
25use uuid::Uuid;
26use vrl::{
27    compiler::{Context, TargetValue, TimeZone, state::RuntimeState},
28    diagnostic::Formatter,
29    value,
30};
31
32pub use self::unit_test_components::{
33    UnitTestSinkCheck, UnitTestSinkConfig, UnitTestSinkResult, UnitTestSourceConfig,
34    UnitTestStreamSinkConfig, UnitTestStreamSourceConfig,
35};
36use super::{OutputId, compiler::expand_globs, graph::Graph, transform::get_transform_output_ids};
37use crate::{
38    conditions::Condition,
39    config::{
40        self, ComponentKey, Config, ConfigBuilder, ConfigPath, SinkOuter, SourceOuter,
41        TestDefinition, TestInput, TestOutput, loading, loading::ConfigBuilderLoader,
42    },
43    event::{Event, EventMetadata, LogEvent},
44    signal,
45    topology::{
46        RunningTopology,
47        builder::{TopologyPieces, TopologyPiecesBuilder},
48    },
49};
50
51pub struct UnitTest {
52    pub name: String,
53    config: Config,
54    pieces: TopologyPieces,
55    test_result_rxs: Vec<Receiver<UnitTestSinkResult>>,
56}
57
58pub struct UnitTestResult {
59    pub errors: Vec<String>,
60}
61
62impl UnitTest {
63    pub async fn run(self) -> UnitTestResult {
64        let diff = config::ConfigDiff::initial(&self.config);
65        let (topology, _) = RunningTopology::start_validated(self.config, diff, self.pieces)
66            .await
67            .unwrap();
68        topology.sources_finished().await;
69        let _stop_complete = topology.stop();
70
71        let mut in_flight = self
72            .test_result_rxs
73            .into_iter()
74            .collect::<FuturesUnordered<_>>();
75
76        let mut errors = Vec::new();
77        while let Some(partial_result) = in_flight.next().await {
78            let partial_result = partial_result.expect(
79                "An unexpected error occurred while executing unit tests. Please try again.",
80            );
81            errors.extend(partial_result.test_errors);
82        }
83
84        UnitTestResult { errors }
85    }
86}
87
88/// Loads Log Schema from configurations and sets global schema.
89/// Once this is done, configurations can be correctly loaded using
90/// configured log schema defaults.
91/// If deny is set, will panic if schema has already been set.
92fn init_log_schema_from_paths(
93    config_paths: &[ConfigPath],
94    deny_if_set: bool,
95) -> Result<(), Vec<String>> {
96    let builder = ConfigBuilderLoader::default().load_from_paths(config_paths)?;
97    vector_lib::config::init_log_schema(builder.global.log_schema, deny_if_set);
98    Ok(())
99}
100
101pub async fn build_unit_tests_main(
102    paths: &[ConfigPath],
103    signal_handler: &mut signal::SignalHandler,
104) -> Result<Vec<UnitTest>, Vec<String>> {
105    init_log_schema_from_paths(paths, false)?;
106    let secrets_backends_loader =
107        loading::loader_from_paths(loading::SecretBackendLoader::default(), paths)?;
108    let secrets = secrets_backends_loader
109        .retrieve_secrets(signal_handler)
110        .await
111        .map_err(|e| vec![e])?;
112
113    let config_builder = ConfigBuilderLoader::default()
114        .secrets(secrets)
115        .load_from_paths(paths)?;
116
117    build_unit_tests(config_builder).await
118}
119
120pub async fn build_unit_tests(
121    mut config_builder: ConfigBuilder,
122) -> Result<Vec<UnitTest>, Vec<String>> {
123    // Sanitize config by removing existing sources and sinks
124    config_builder.sources = Default::default();
125    config_builder.sinks = Default::default();
126
127    let test_definitions = std::mem::take(&mut config_builder.tests);
128    let mut tests = Vec::new();
129    let mut build_errors = Vec::new();
130    let metadata = UnitTestBuildMetadata::initialize(&mut config_builder)?;
131
132    for mut test_definition in test_definitions {
133        let test_name = test_definition.name.clone();
134        // Move the legacy single test input into the inputs list if it exists
135        let legacy_input = std::mem::take(&mut test_definition.input);
136        if let Some(input) = legacy_input {
137            test_definition.inputs.push(input);
138        }
139        match build_unit_test(&metadata, test_definition, config_builder.clone()).await {
140            Ok(test) => tests.push(test),
141            Err(errors) => {
142                let mut test_error = errors.join("\n");
143                // Indent all line breaks
144                test_error = test_error.replace('\n', "\n  ");
145                test_error.insert_str(0, &format!("Failed to build test '{test_name}':\n  "));
146                build_errors.push(test_error);
147            }
148        }
149    }
150
151    if build_errors.is_empty() {
152        Ok(tests)
153    } else {
154        Err(build_errors)
155    }
156}
157
158pub struct UnitTestBuildMetadata {
159    // A set of all valid insert_at targets, used to validate test inputs.
160    available_insert_targets: HashSet<ComponentKey>,
161    // A mapping from transform name to unit test source name.
162    source_ids: HashMap<ComponentKey, String>,
163    // A base setup of all necessary unit test sources that can be "hydrated"
164    // with test input events to produces sources used in a particular test.
165    template_sources: IndexMap<ComponentKey, UnitTestSourceConfig>,
166    // A mapping from transform name to unit test sink name.
167    sink_ids: HashMap<OutputId, String>,
168}
169
170impl UnitTestBuildMetadata {
171    pub fn initialize(config_builder: &mut ConfigBuilder) -> Result<Self, Vec<String>> {
172        // A unique id used to name test sources and sinks to avoid name clashes
173        let random_id = Uuid::new_v4().to_string();
174
175        let available_insert_targets = config_builder
176            .transforms
177            .keys()
178            .cloned()
179            .collect::<HashSet<_>>();
180
181        let source_ids = available_insert_targets
182            .iter()
183            .map(|key| (key.clone(), format!("{}-{}-{}", key, "source", random_id)))
184            .collect::<HashMap<_, _>>();
185
186        // Map a test source to every transform
187        let mut template_sources = IndexMap::new();
188        for (key, transform) in config_builder.transforms.iter_mut() {
189            let test_source_id = source_ids
190                .get(key)
191                .expect("Missing test source for a transform")
192                .clone();
193            transform.inputs.extend(Some(test_source_id));
194
195            template_sources.insert(key.clone(), UnitTestSourceConfig::default());
196        }
197
198        let builder = config_builder.clone();
199        let available_extract_targets = builder
200            .transforms
201            .iter()
202            .flat_map(|(key, transform)| {
203                get_transform_output_ids(
204                    transform.inner.as_ref(),
205                    key.clone(),
206                    builder.schema.log_namespace(),
207                )
208            })
209            .collect::<HashSet<_>>();
210
211        let sink_ids = available_extract_targets
212            .iter()
213            .map(|key| {
214                (
215                    key.clone(),
216                    format!(
217                        "{}-{}-{}",
218                        key.to_string().replace('.', "-"),
219                        "sink",
220                        random_id
221                    ),
222                )
223            })
224            .collect::<HashMap<_, _>>();
225
226        Ok(Self {
227            available_insert_targets,
228            source_ids,
229            template_sources,
230            sink_ids,
231        })
232    }
233
234    /// Convert test inputs into sources for use in a unit testing topology
235    pub fn hydrate_into_sources(
236        &self,
237        inputs: &[TestInput],
238    ) -> Result<IndexMap<ComponentKey, SourceOuter>, Vec<String>> {
239        let inputs = build_and_validate_inputs(inputs, &self.available_insert_targets)?;
240        let mut template_sources = self.template_sources.clone();
241        Ok(inputs
242            .into_iter()
243            .map(|(insert_at, events)| {
244                let mut source_config =
245                    template_sources
246                        .shift_remove(&insert_at)
247                        .unwrap_or_else(|| {
248                            // At this point, all inputs should have been validated to
249                            // correspond with valid transforms, and all valid transforms
250                            // have a source attached.
251                            panic!(
252                                "Invalid input: cannot insert at {:?}",
253                                insert_at.to_string()
254                            )
255                        });
256                source_config.events.extend(events);
257                let id: &str = self
258                    .source_ids
259                    .get(&insert_at)
260                    .expect("Corresponding source must exist")
261                    .as_ref();
262                (ComponentKey::from(id), SourceOuter::new(source_config))
263            })
264            .collect::<IndexMap<_, _>>())
265    }
266
267    /// Convert test outputs into sinks for use in a unit testing topology
268    pub fn hydrate_into_sinks(
269        &self,
270        test_name: &str,
271        outputs: &[TestOutput],
272        no_outputs_from: &[OutputId],
273    ) -> Result<
274        (
275            Vec<Receiver<UnitTestSinkResult>>,
276            IndexMap<ComponentKey, SinkOuter<String>>,
277        ),
278        Vec<String>,
279    > {
280        if outputs.is_empty() && no_outputs_from.is_empty() {
281            return Err(vec![
282                "unit test must contain at least one of `outputs` or `no_outputs_from`."
283                    .to_string(),
284            ]);
285        }
286        let outputs = build_outputs(outputs)?;
287
288        let mut template_sinks = IndexMap::new();
289        let mut test_result_rxs = Vec::new();
290        // Add sinks with checks
291        for (ids, built) in outputs {
292            let (tx, rx) = oneshot::channel();
293            let sink_ids = ids.clone();
294            let sink_config = UnitTestSinkConfig {
295                test_name: test_name.to_string(),
296                transform_ids: ids.iter().map(|id| id.to_string()).collect(),
297                result_tx: Arc::new(Mutex::new(Some(tx))),
298                check: UnitTestSinkCheck::Checks {
299                    conditions: built.conditions,
300                    expected_event_count: built.expected_event_count,
301                },
302            };
303
304            test_result_rxs.push(rx);
305            template_sinks.insert(sink_ids, sink_config);
306        }
307
308        // Add sinks with no outputs check
309        for id in no_outputs_from {
310            let (tx, rx) = oneshot::channel();
311            let sink_config = UnitTestSinkConfig {
312                test_name: test_name.to_string(),
313                transform_ids: vec![id.to_string()],
314                result_tx: Arc::new(Mutex::new(Some(tx))),
315                check: UnitTestSinkCheck::NoOutputs,
316            };
317
318            test_result_rxs.push(rx);
319            template_sinks.insert(vec![id.clone()], sink_config);
320        }
321
322        let sinks = template_sinks
323            .into_iter()
324            .map(|(transform_ids, sink_config)| {
325                let transform_ids_str = transform_ids
326                    .iter()
327                    .map(|s| s.to_string())
328                    .collect::<Vec<_>>();
329                let sink_ids = transform_ids
330                    .iter()
331                    .map(|transform_id| {
332                        self.sink_ids
333                            .get(transform_id)
334                            .expect("Sink does not exist")
335                            .as_str()
336                    })
337                    .collect::<Vec<_>>();
338                let sink_id = sink_ids.join(",");
339                (
340                    ComponentKey::from(sink_id),
341                    SinkOuter::new(transform_ids_str, sink_config),
342                )
343            })
344            .collect::<IndexMap<_, _>>();
345
346        Ok((test_result_rxs, sinks))
347    }
348}
349
350// Find all components that participate in the test
351fn get_relevant_test_components(
352    sources: &[&ComponentKey],
353    graph: &Graph,
354) -> Result<HashSet<String>, Vec<String>> {
355    graph.check_for_cycles().map_err(|error| vec![error])?;
356    let mut errors = Vec::new();
357    let mut components = HashSet::new();
358    for source in sources {
359        let paths = graph.paths_to_sink_from(source);
360        if paths.is_empty() {
361            errors.push(format!(
362                "Unable to complete topology between input target '{}' and output target(s)",
363                source
364                    .to_string()
365                    .rsplit_once("-source-")
366                    .unwrap_or(("", ""))
367                    .0
368            ));
369        } else {
370            for path in paths {
371                components.extend(path.into_iter().map(|key| key.to_string()));
372            }
373        }
374    }
375
376    if errors.is_empty() {
377        Ok(components)
378    } else {
379        Err(errors)
380    }
381}
382
383async fn build_unit_test(
384    metadata: &UnitTestBuildMetadata,
385    test: TestDefinition<String>,
386    mut config_builder: ConfigBuilder,
387) -> Result<UnitTest, Vec<String>> {
388    let transform_only_config = config_builder.clone();
389    let transform_only_graph = Graph::new_unchecked(
390        &transform_only_config.sources,
391        &transform_only_config.transforms,
392        &transform_only_config.sinks,
393        transform_only_config.schema,
394        transform_only_config
395            .global
396            .wildcard_matching
397            .unwrap_or_default(),
398    );
399    let test = test.resolve_outputs(&transform_only_graph)?;
400
401    let sources = metadata.hydrate_into_sources(&test.inputs)?;
402    let (test_result_rxs, sinks) =
403        metadata.hydrate_into_sinks(&test.name, &test.outputs, &test.no_outputs_from)?;
404
405    config_builder.sources = sources;
406    config_builder.sinks = sinks;
407    expand_globs(&mut config_builder);
408
409    let graph = Graph::new_unchecked(
410        &config_builder.sources,
411        &config_builder.transforms,
412        &config_builder.sinks,
413        config_builder.schema,
414        config_builder.global.wildcard_matching.unwrap_or_default(),
415    );
416
417    let mut valid_components = get_relevant_test_components(
418        config_builder.sources.keys().collect::<Vec<_>>().as_ref(),
419        &graph,
420    )?;
421
422    // Preserve the original unexpanded transform(s) which are valid test insertion points
423    let unexpanded_transforms = valid_components
424        .iter()
425        .filter_map(|component| {
426            component
427                .split_once('.')
428                .map(|(original_name, _)| original_name.to_string())
429        })
430        .collect::<Vec<_>>();
431    valid_components.extend(unexpanded_transforms);
432
433    // Enrichment tables consume inputs but are referenced dynamically in VRL transforms
434    // (via get_enrichment_table_record). Since we can't statically analyze VRL usage,
435    // we conservatively include all enrichment table inputs as valid components.
436    config_builder
437        .enrichment_tables
438        .iter()
439        .filter_map(|(key, c)| c.as_sink(key).map(|(_, sink)| sink.inputs))
440        .for_each(|i| valid_components.extend(i));
441
442    // Remove all transforms that are not relevant to the current test
443    config_builder.transforms = config_builder
444        .transforms
445        .into_iter()
446        .filter(|(key, _)| valid_components.contains(&key.to_string()))
447        .collect();
448
449    // Sanitize the inputs of all relevant transforms
450    let graph = Graph::new_unchecked(
451        &config_builder.sources,
452        &config_builder.transforms,
453        &config_builder.sinks,
454        config_builder.schema,
455        config_builder.global.wildcard_matching.unwrap_or_default(),
456    );
457    let valid_inputs = graph.input_map()?;
458    for (_, transform) in config_builder.transforms.iter_mut() {
459        let inputs = std::mem::take(&mut transform.inputs);
460        transform.inputs = inputs
461            .into_iter()
462            .filter(|input| valid_inputs.contains_key(input))
463            .collect();
464    }
465
466    if let Some(sink) = get_loose_end_outputs_sink(&config_builder) {
467        config_builder
468            .sinks
469            .insert(ComponentKey::from(Uuid::new_v4().to_string()), sink);
470    }
471    let config = config_builder.build()?;
472    let diff = config::ConfigDiff::initial(&config);
473    let pieces = TopologyPiecesBuilder::new(&config, &diff).build().await?;
474
475    Ok(UnitTest {
476        name: test.name,
477        config,
478        pieces,
479        test_result_rxs,
480    })
481}
482
483/// Near the end of building a unit test, it's possible that we've included a
484/// transform(s) with multiple outputs where at least one of its output is
485/// consumed but its other outputs are left unconsumed.
486///
487/// To avoid warning logs that occur when building such topologies, we construct
488/// a NoOp sink here whose sole purpose is to consume any "loose end" outputs.
489fn get_loose_end_outputs_sink(config: &ConfigBuilder) -> Option<SinkOuter<String>> {
490    let config = config.clone();
491    let transform_ids = config.transforms.iter().flat_map(|(key, transform)| {
492        get_transform_output_ids(
493            transform.inner.as_ref(),
494            key.clone(),
495            config.schema.log_namespace(),
496        )
497        .map(|output| output.to_string())
498        .collect::<Vec<_>>()
499    });
500
501    let mut loose_end_outputs = Vec::new();
502    for id in transform_ids {
503        if !config
504            .transforms
505            .iter()
506            .any(|(_, transform)| transform.inputs.contains(&id))
507            && !config
508                .sinks
509                .iter()
510                .any(|(_, sink)| sink.inputs.contains(&id))
511        {
512            loose_end_outputs.push(id);
513        }
514    }
515
516    if loose_end_outputs.is_empty() {
517        None
518    } else {
519        let noop_sink = UnitTestSinkConfig {
520            test_name: "".to_string(),
521            transform_ids: vec![],
522            result_tx: Arc::new(Mutex::new(None)),
523            check: UnitTestSinkCheck::NoOp,
524        };
525        Some(SinkOuter::new(loose_end_outputs, noop_sink))
526    }
527}
528
529fn build_and_validate_inputs(
530    test_inputs: &[TestInput],
531    available_insert_targets: &HashSet<ComponentKey>,
532) -> Result<HashMap<ComponentKey, Vec<Event>>, Vec<String>> {
533    let mut inputs = HashMap::new();
534    let mut errors = Vec::new();
535    if test_inputs.is_empty() {
536        errors.push("must specify at least one input.".to_string());
537        return Err(errors);
538    }
539
540    for (index, input) in test_inputs.iter().enumerate() {
541        if available_insert_targets.contains(&input.insert_at) {
542            match build_input_event(input) {
543                Ok(input_event) => {
544                    inputs
545                        .entry(input.insert_at.clone())
546                        .and_modify(|events: &mut Vec<Event>| {
547                            events.push(input_event.clone());
548                        })
549                        .or_insert_with(|| vec![input_event]);
550                }
551                Err(error) => errors.push(error),
552            }
553        } else {
554            errors.push(format!(
555                "inputs[{}]: unable to locate target transform '{}'",
556                index, input.insert_at
557            ))
558        }
559    }
560
561    if errors.is_empty() {
562        Ok(inputs)
563    } else {
564        Err(errors)
565    }
566}
567
568#[derive(Default)]
569pub(super) struct BuiltOutput {
570    pub(super) expected_event_count: Option<usize>,
571    pub(super) conditions: Vec<Vec<Condition>>,
572}
573
574fn build_outputs(
575    test_outputs: &[TestOutput],
576) -> Result<IndexMap<Vec<OutputId>, BuiltOutput>, Vec<String>> {
577    let mut outputs: IndexMap<Vec<OutputId>, BuiltOutput> = IndexMap::new();
578    let mut errors = Vec::new();
579
580    for output in test_outputs {
581        let mut conditions = Vec::new();
582        for (index, condition) in output
583            .conditions
584            .clone()
585            .unwrap_or_default()
586            .iter()
587            .enumerate()
588        {
589            match condition.build(&Default::default(), &Default::default()) {
590                Ok(condition) => conditions.push(condition),
591                Err(error) => errors.push(format!(
592                    "failed to create test condition '{index}': {error}"
593                )),
594            }
595        }
596
597        let expected_event_count = output.expected_event_count;
598        if expected_event_count == Some(0) && !conditions.is_empty() {
599            errors.push(format!(
600                "output for {:?} has expected_event_count of 0 but also defines conditions; \
601                 conditions cannot be evaluated when no events are expected",
602                output.extract_from
603            ));
604        }
605        outputs
606            .entry(output.extract_from.clone().to_vec())
607            .and_modify(|existing| {
608                if let (Some(prev), Some(new)) =
609                    (existing.expected_event_count, expected_event_count)
610                {
611                    if prev != new {
612                        errors.push(format!(
613                            "conflicting expected_event_count for extract_from {:?}: {} vs {}",
614                            output.extract_from, prev, new
615                        ));
616                    }
617                } else if existing.expected_event_count.is_none() {
618                    existing.expected_event_count = expected_event_count;
619                }
620                existing.conditions.push(conditions.clone());
621            })
622            .or_insert_with(|| BuiltOutput {
623                expected_event_count,
624                conditions: vec![conditions.clone()],
625            });
626    }
627
628    // Post-merge validation: after merging entries that share the same
629    // extract_from, reject any that ended up with expected_event_count of 0 and
630    // non-empty conditions (which would pass vacuously against zero events).
631    for (extract_from, built) in &outputs {
632        if built.expected_event_count == Some(0) && built.conditions.iter().any(|c| !c.is_empty()) {
633            errors.push(format!(
634                "output for {extract_from:?} has expected_event_count of 0 but also defines conditions; \
635                 conditions cannot be evaluated when no events are expected",
636            ));
637        }
638    }
639
640    if errors.is_empty() {
641        Ok(outputs)
642    } else {
643        Err(errors)
644    }
645}
646
647fn build_input_event(input: &TestInput) -> Result<Event, String> {
648    match input.type_str.as_ref() {
649        "raw" => match input.value.as_ref() {
650            Some(v) => Ok(Event::Log(LogEvent::from_str_legacy(v.clone()))),
651            None => Err("input type 'raw' requires the field 'value'".to_string()),
652        },
653        "vrl" => {
654            if let Some(source) = &input.source {
655                let result = vrl::compiler::compile(source, &vector_vrl_functions::all())
656                    .map_err(|e| Formatter::new(source, e.clone()).to_string())?;
657
658                let mut target = TargetValue {
659                    value: value!({}),
660                    metadata: value::Value::Object(BTreeMap::new()),
661                    secrets: value::Secrets::default(),
662                };
663
664                let mut state = RuntimeState::default();
665                let timezone = TimeZone::default();
666                let mut ctx = Context::new(&mut target, &mut state, &timezone);
667
668                result
669                    .program
670                    .resolve(&mut ctx)
671                    .map(|_| {
672                        Event::Log(LogEvent::from_parts(
673                            target.value.clone(),
674                            EventMetadata::default_with_value(target.metadata.clone()),
675                        ))
676                    })
677                    .map_err(|e| e.to_string())
678            } else {
679                Err("input type 'vrl' requires the field 'source'".to_string())
680            }
681        }
682        "log" => {
683            if let Some(log_fields) = &input.log_fields {
684                let mut event = LogEvent::from_str_legacy("");
685                for (path, value) in log_fields {
686                    event
687                        .parse_path_and_insert(path, value.clone())
688                        .map_err(|e| e.to_string())?;
689                }
690                Ok(event.into())
691            } else {
692                Err("input type 'log' requires the field 'log_fields'".to_string())
693            }
694        }
695        "metric" => {
696            if let Some(metric) = &input.metric {
697                Ok(Event::Metric(metric.clone()))
698            } else {
699                Err("input type 'metric' requires the field 'metric'".to_string())
700            }
701        }
702        _ => Err(format!(
703            "unrecognized input type '{}', expected one of: 'raw', 'log' or 'metric'",
704            input.type_str
705        )),
706    }
707}