Pull to refresh

Как ExpressionTrees помогают тестировать WebApi

Reading time5 min
Views5K
Всем хороши ApiController'ы, да не создают они WSDL и нельзя просто так взять и получить proxy. Да, ApiController'ы неплохо тестируются unit-test'ами. Но юниты пропускают ошибки транспортного уровня и в целом без парочки end-to-end сценариев как-то неудобно. Можно конечно смириться, взять HttpClient и написать примерно такой код:

HttpClient client = new HttpClient();
client.BaseAddress = new Uri("http://localhost:56851/");

// Add an Accept header for JSON format.
client.DefaultRequestHeaders.Accept.Add(
    new MediaTypeWithQualityHeaderValue("application/json"));

HttpResponseMessage response = client.GetAsync("api/User").Result;

if (response.IsSuccessStatusCode)
{
    var users = response.Content.ReadAsAsync<IEnumerable<Users>>().Result;
    usergrid.ItemsSource = users;

}
else
{
    MessageBox.Show("Error Code" + 
    response.StatusCode + " : Message - " + response.ReasonPhrase);
}

Но как же это муторно каждый раз лезть в описание контроллеров, проверять типы, короче хочется вот так:
var resp = GetResponse<SomeController>(c => gc.SomeAction(new Dto(){val = "123"}));

Как выяснилось, это вполне можно реализовать применив немного уличной магии деревья выражений

Получение информации об API

для начала нам нужно знать какое API вообще есть, для этого замапим роуты
[SetUp]
public void SetUp()
{
    _cfg = new HttpConfiguration();
    _cfg.Routes.MapHttpRoute(
            name: "DefaultApi",
        routeTemplate: "api/{controller}/{action}/{id}",
        defaults: new { id = RouteParameter.Optional }
    );
}

Вызов удаленного метода

ApiDescriptions теперь знает о том, где искать контроллеры и любезно предоставит метаинформацию. В WebApi может быть много вариантов вызова одного метода: я никогда не использую два Http-метода для одного метода API, поэтому этот кейс меня не волнует. С чистой совестью возьмем первый подходящий метод
protected HttpResponseMessage GetResponse<T>(Expression<Action<T>> expression)
    where T : ApiController
{
    var baseAddress = System.Configuration.ConfigurationManager.AppSettings["BaseAddress"];
    var convert = (MethodCallExpression)expression.Body;
    var name = convert.Method.Name;
    var pars = convert.Method.GetParameters().ToArray();

    var desc = _cfg.Services.GetApiExplorer().ApiDescriptions.First(
        d =>
            d.ActionDescriptor.ControllerDescriptor.ControllerType == typeof(T) &&
            d.ActionDescriptor.ActionName == name);
  //...


Допущение №2. Кроме JSON мне ничего не интересно. Для get-методов и post-с примитивами в параметрах заменим вхождения вида paramName={paramName} на paramName=значение из Expression, которое мы передали.
using (var client = new HttpClient { BaseAddress = new Uri(baseAddress) })
{
    client.DefaultRequestHeaders.Accept.Add(
        new MediaTypeWithQualityHeaderValue("application/json"));

    var relPath = desc.RelativePath;

    var index = 0;

    if (relPath.Contains("?"))
    {
        foreach (var p in pars)
        {
            relPath = relPath.Replace(
                string.Format("{{{0}}}", pars.Name),
                InvokeExpression(convert.Arguments[index++], p.ParameterType).Return(o => o.ToString(), string.Empty));
        }
     }

InvokeExpression

Самый простой способ получить значение любого Expression — скомпилировать его в лямбду, что я и сделал. Откровенно говоря, мы знаем возвращаемый тип на этапе компиляции из типа контроллера. Но в этом случае придется делать отдельный кейс для методов, возвращающих void. В этом случае придется использовать Action вместо Func<T,TResult>. Такой код уже достаточно сложен для понимания. За производительностью я не гонюсь, — сетевые издержки сожрут все те наносекунды, которые будут сэкономлены на компиляции.
private static object InvokeExpression(Expression e, Type returnType)
{
    return Expression.Lambda(
        typeof (Func<>).MakeGenericType(returnType),
        e).Compile().DynamicInvoke();
}

Получение результата

Осталось самое простое — получить результат и вызвать метод. Для Post методов считаем, что всегда есть родительский объект-wrapper. Возвращаем результат или падаем с ошибкой.
var uri = new Uri(new Uri(baseAddress), relPath);

var resp = desc.HttpMethod.Method == HttpMethod.Post.ToString()
    ? client.PostAsJsonAsync(uri.ToString(), InvokeExpression(convert.Arguments.Single(),
    desc.ParameterDescriptions.Single().ParameterDescriptor.ParameterType)).Result
    : client.GetAsync(uri).Result;

if (resp.StatusCode == HttpStatusCode.InternalServerError)
{
    using (var sr = new StreamReader(resp.Content.ReadAsStreamAsync().Result))
    {
        throw new InvalidOperationException(sr.ReadToEnd());
    }                    
}
return resp;

В итоге

Получился вот такой метод
protected HttpResponseMessage GetResponse<T>(Expression<Action<T>> expression)
    where T : ApiController
{
    var baseAddress = System.Configuration.ConfigurationManager.AppSettings["BaseAddress"];
    var convert = (MethodCallExpression)expression.Body;
    var name = convert.Method.Name;
    var pars = convert.Method.GetParameters().ToArray();

    var desc = _cfg.Services.GetApiExplorer().ApiDescriptions.First(
        d =>
            d.ActionDescriptor.ControllerDescriptor.ControllerType == typeof(T) &&
            d.ActionDescriptor.ActionName == name);

    using (var client = new HttpClient { BaseAddress = new Uri(baseAddress) })
    {
        client.DefaultRequestHeaders.Accept.Add(
            new MediaTypeWithQualityHeaderValue("application/json"));

        var relPath = desc.RelativePath;

        var index = 0;

        if (relPath.Contains("?"))
        foreach (var p in pars)
        {
            relPath = relPath.Replace(
                string.Format("{{{0}}}", p.Name),
                InvokeExpression(convert.Arguments[index++], p.ParameterType).Return(o => o.ToString(), string.Empty));
        }
                
        var uri = new Uri(new Uri(baseAddress), relPath);

        var resp = desc.HttpMethod.Method == HttpMethod.Post.ToString()
            ? client.PostAsJsonAsync(uri.ToString(), InvokeExpression(convert.Arguments.Single(),
            desc.ParameterDescriptions.Single().ParameterDescriptor.ParameterType)).Result
            : client.GetAsync(uri).Result;

        if (resp.StatusCode == HttpStatusCode.InternalServerError)
        {
            using (var sr = new StreamReader(resp.Content.ReadAsStreamAsync().Result))
            {
                throw new InvalidOperationException(sr.ReadToEnd());
            }
                    
        }
        return resp;
    }
}

Много чего в этом коде не идеально, но свою цель он выполнят, теперь я могу писать такие тесты:
[Test]
public void UserController_TokenValid_WrongTokenReturnFalse()
{
    var resp = GetResponse<UserController>(gc => gc.TokenValid("123"));
    Assert.AreEqual(false, resp.Content.ReadAsAsync<bool>().Result);
}

Или более сложные, например такие:
var obj = new RoundResultDtoIn()
{
    LevelId = 3,
    RoomName = "123",
    RoundTime = 50,
    StartDateTime = DateTime.Now
};
            
GetResponse<GameController>(gc => gc.SaveResults(obj));
Tags:
Hubs:
Total votes 15: ↑13 and ↓2+11
Comments3

Articles