All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.fnproject.fn.testing.FnTestingRule Maven / Gradle / Ivy

The newest version!
/*
 * Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved.
 *
 * 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
 *
 *     http://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.fnproject.fn.testing;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fnproject.fn.api.*;
import com.fnproject.fn.runtime.EventCodec;
import org.apache.commons.io.output.TeeOutputStream;
import org.junit.Rule;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;

import java.io.*;
import java.util.*;


/**
 * Testing {@link Rule} for fn Java FDK functions.
 * 

* This interface facilitates: *

    *
  • The creation of an in-memory environment replicating the functionality of the {@code fn} service
  • *
  • The creation of input events passed to a user function using {@link #givenEvent()}
  • *
  • The verification of function behaviour by accessing output represented by {@link FnResult} instances.
  • *
*

Example Usage:

*
{@code
 * public class MyFunctionTest {
 *     {@literal @}Rule
 *     public final FnTestingRule testing = FnTestingRule.createDefault();
 *
 *     {@literal @}Test
 *     public void myTest() {
 *         // Create an event to invoke MyFunction and put it into the event queue
 *         fn.givenEvent()
 *            .addHeader("FOO", "BAR")
 *            .withBody("Body")
 *            .enqueue();
 *
 *         // Run MyFunction#handleRequest using the built event queue from above
 *         fn.thenRun(MyFunction.class, "handleRequest");
 *
 *         // Get the function result and check it for correctness
 *         FnResult result = fn.getOnlyResult();
 *         assertThat(result.getStatus()).isEqualTo(200);
 *         assertThat(result.getBodyAsString()).isEqualTo("expected return value of my function");
 *     }
 * }}
*/ public final class FnTestingRule implements TestRule { private final Map config = new HashMap<>(); private Map eventEnv = new HashMap<>(); private boolean hasEvents = false; private List pendingInput = Collections.synchronizedList(new ArrayList<>()); private List output = Collections.synchronizedList(new ArrayList<>()); private ByteArrayOutputStream stdErr = new ByteArrayOutputStream(); private static final ObjectMapper objectMapper = new ObjectMapper(); private final List sharedPrefixes = new ArrayList<>(); private int lastExitCode; private final List features = new ArrayList<>(); { // Internal shared classes required to bridge completer into tests addSharedClassPrefix("java."); addSharedClassPrefix("javax."); addSharedClassPrefix("sun."); addSharedClassPrefix("jdk."); addSharedClass(Headers.class); addSharedClass(InputEvent.class); addSharedClass(OutputEvent.class); addSharedClass(OutputEvent.Status.class); addSharedClass(TestOutput.class); addSharedClass(TestCodec.class); addSharedClass(EventCodec.class); addSharedClass(EventCodec.Handler.class); } /** * TestOutput represents an output of a function it wraps OutputEvent and provides buffered access to the function output */ public static final class TestOutput implements FnResult { private final OutputEvent from; byte[] body; private TestOutput(OutputEvent from) throws IOException { this.from = Objects.requireNonNull(from, "from"); ByteArrayOutputStream bos = new ByteArrayOutputStream(); from.writeToOutput(bos); body = bos.toByteArray(); } /** * construct a test output from an output event - this consums the body of the output event * * @param from an output event to consume * @return a new TestEvent that wraps the passed even t * @throws IOException */ public static TestOutput fromOutputEvent(OutputEvent from) throws IOException { return new TestOutput(from); } @Override public Status getStatus() { return from.getStatus(); } @Override public Optional getContentType() { return from.getContentType(); } @Override public Headers getHeaders() { return from.getHeaders(); } @Override public void writeToOutput(OutputStream out) throws IOException { out.write(body); } @Override public byte[] getBodyAsBytes() { return body; } /** * Returns the buffered body of the event as a string * * @return the body of the event as a string */ @Override public String getBodyAsString() { return new String(body); } } private FnTestingRule() { } public void addFeature(FnTestingRuleFeature f) { this.features.add(f); } /** * Create an instance of the testing {@link Rule}, with Flows support * * @return a new test rule */ public static FnTestingRule createDefault() { return new FnTestingRule(); } /** * Add a config variable to the function for the test *

* Config names will be translated to upper case with hyphens and spaces translated to _. Clashing config keys will * be overwritten. * * @param key the configuration key * @param value the configuration value * @return the current test rule */ public FnTestingRule setConfig(String key, String value) { config.put(key.toUpperCase().replaceAll("[- ]", "_"), value); return this; } /** * Add a class or package name to be forked during the test. * The test will be run under the aegis of a classloader that duplicates the class hierarchy named. * * @param prefix A class name or package prefix, such as "com.example.foo." */ public FnTestingRule addSharedClassPrefix(String prefix) { sharedPrefixes.add(prefix); return this; } /** * Add a class to be forked during the test. * The test will be run under the aegis of a classloader that duplicates the class hierarchy named. * * @param cls A class */ public FnTestingRule addSharedClass(Class cls) { sharedPrefixes.add("=" + cls.getName()); return this; } @Override public Statement apply(Statement base, Description description) { return base; } /** * Create an HTTP event builder for the function * * @return a new event builder */ public FnEventBuilderJUnit4 givenEvent() { return new DefaultFnEventBuilder(); } /** * Runs the function runtime with the specified class and method (and waits for Flow stages to finish * if the test spawns any flows) * * @param cls class to thenRun * @param method the method name */ public void thenRun(Class cls, String method) { thenRun(cls.getName(), method); } public static class TestCodec implements EventCodec { private final List input; private final List output; public TestCodec(List input, List output) { this.input = input; this.output = output; } @Override public void runCodec(Handler h) { for (InputEvent in : input) { try { output.add(new TestOutput(h.handle(in))); } catch (IOException e) { throw new RuntimeException("Unexpected exception in test", e); } } } } /** * Runs the function runtime with the specified class and method (and waits for Flow stages to finish * if the test spawns any Flow) * * @param cls class to thenRun * @param method the method name */ public void thenRun(String cls, String method) { final ClassLoader functionClassLoader; Class c = null; try { // Trick to work around Maven class loader separation // if passed class is a valid class then set the classloader to the same as the class's loader c = Class.forName(cls); } catch (Exception ignored) { // TODO don't fall through here } if (c != null) { functionClassLoader = c.getClassLoader(); } else { functionClassLoader = getClass().getClassLoader(); } PrintStream oldSystemOut = System.out; PrintStream oldSystemErr = System.err; for (FnTestingRuleFeature f : features) { f.prepareTest(functionClassLoader, oldSystemErr, cls, method); } Map mutableEnv = new HashMap<>(); try { PrintStream functionErr = new PrintStream(new TeeOutputStream(stdErr, oldSystemErr)); System.setOut(functionErr); System.setErr(functionErr); mutableEnv.putAll(config); mutableEnv.putAll(eventEnv); mutableEnv.put("FN_FORMAT", "http-stream"); mutableEnv.put("FN_FN_ID","myFnID"); mutableEnv.put("FN_APP_ID","myAppID"); FnTestingClassLoader forked = new FnTestingClassLoader(functionClassLoader, sharedPrefixes); if (forked.isShared(cls)) { oldSystemErr.println("WARNING: The function class under test is shared with the test ClassLoader."); oldSystemErr.println(" This may result in unexpected behaviour around function initialization and configuration."); } for (FnTestingRuleFeature f : features) { f.prepareFunctionClassLoader(forked); } TestCodec codec = new TestCodec(pendingInput, output); lastExitCode = forked.run( mutableEnv, codec, functionErr, cls + "::" + method); stdErr.flush(); for (FnTestingRuleFeature f : features) { f.afterTestComplete(); } } catch (Exception e) { throw new RuntimeException("internal error raised by entry point or flushing the test streams", e); } finally { System.out.flush(); System.err.flush(); System.setOut(oldSystemOut); System.setErr(oldSystemErr); } } /** * Get the exit code from the most recent invocation * 0 = success * 1 = failed * 2 = not run due to initialization error */ public int getLastExitCode() { return lastExitCode; } /** * Get the StdErr stream returned by the function as a byte array * * @return the StdErr stream as bytes from the runtime */ public byte[] getStdErr() { return stdErr.toByteArray(); } /** * Gets the StdErr stream returned by the function as a String * * @return the StdErr stream as a string from the function */ public String getStdErrAsString() { return new String(stdErr.toByteArray()); } /** * Parses any pending HTTP responses on the functions output stream and returns the corresponding FnResult instances * * @return a list of Parsed HTTP responses (as {@link FnResult}s) from the function runtime output */ public List getResults() { return output; } /** * Convenience method to get the one and only parsed http response expected on the output of the function * * @return a single parsed HTTP response from the function runtime output * @throws IllegalStateException if zero or more than one responses were produced */ public FnResult getOnlyResult() { List results = getResults(); if (results.size() == 1) { return results.get(0); } throw new IllegalStateException("One and only one response expected, but " + results.size() + " responses were generated."); } public List getSharedPrefixes() { return Collections.unmodifiableList(sharedPrefixes); } public Map getConfig() { return Collections.unmodifiableMap(config); } public Map getEventEnv() { return Collections.unmodifiableMap(eventEnv); } /** * Builds a mocked input event into the function runtime */ private class DefaultFnEventBuilder implements FnEventBuilderJUnit4 { FnHttpEventBuilder builder = new FnHttpEventBuilder(); @Override public FnEventBuilder withHeader(String key, String value) { builder.withHeader(key, value); return this; } @Override public FnEventBuilder withBody(InputStream body) throws IOException { builder.withBody(body); return this; } @Override public FnEventBuilder withBody(byte[] body) { builder.withBody(body); return this; } @Override public FnEventBuilder withBody(String body) { builder.withBody(body); return this; } @Override public FnTestingRule enqueue() { pendingInput.add(builder.buildEvent()); return FnTestingRule.this; } @Override public FnTestingRule enqueue(int n) { if (n <= 0) { throw new IllegalArgumentException("Invalid count"); } for (int i = 0; i < n; i++) { enqueue(); } return FnTestingRule.this; } } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy