一个更好的 Visual Studio 2008/2010 开发服务器测试工具





5.00/5 (6投票s)
在测试和其他场景中利用内置的开发 Web 服务器。
前言
这里介绍了 2 种利用 Visual Studio 开发服务器的策略,一种是作为进程外的 exe,另一种是作为进程内的库,用于集成测试基于 ASP.NET 的应用程序,包括 Silverlight 和 MVC。
前者更多的是作为对之前迭代的完成练习。 后者 WebHostServer 是最精简的,也可能是首选方法。
介绍
在之前的文章中,我介绍了一个类,它可以对 Visual Studio 2008 开发服务器 *WebDev.WebServer.exe* 进行程序化控制。 此功能的使用场景是在测试 Web 应用程序和终结点方面。
重构
虽然之前的实现在交互式测试运行器中可以使用,但存在一些资源管理问题,影响了在更自主的场景(如持续集成)中的可用性。
其中包括使用静态端口分配,以及缺少关闭实例的方法,特别是能够在单个测试工具的范围内启动和停止实例的能力。
在此实现中,这些问题已通过提供更强大的方法来识别和控制 *WebDev.WebServer.exe* 的正在运行的实例来解决,该方法使用 WMI 数据和端口使用情况轮询来启用动态端口分配。
唯一未解决的问题是无法删除以编程方式关闭实例时在托盘区域中孤立的通知图标。
这可能在将来作为学术问题来处理,但此实现仅作为验证完全控制可执行文件的外壳实例是可行的。
还有另一种方法可以到达我们想要的地方,但首先让我们更新 *WebDev.WebServer.exe* 包装器。
WebDevServer
将 WebDevServer 与 NUnit 结合使用
using System.Net;
using NUnit.Framework;
namespace Salient.Excerpts
{
[TestFixture]
public class WebDevServerFixture : WebDevServer
{
[TestFixtureSetUp]
public void TestFixtureSetUp()
{
StartServer(@"..\..\..\..\TestSite");
// is the equivalent of
// StartServer(@"..\..\..\..\TestSite",
// GetAvailablePort(8000, 10000, IPAddress.Loopback, true),
// "/", "localhost");
}
[TestFixtureTearDown]
public void TestFixtureTearDown()
{
StopServer();
}
[Test]
public void Test()
{
string html = new WebClient().DownloadString(NormalizeUri("Default.aspx"));
}
}
}
WebDevServer.cs
// Project: Salient
// http://salient.codeplex.com
// Date: April 16 2010
#region
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Management;
using System.Net;
using System.Net.NetworkInformation;
using System.Text.RegularExpressions;
using System.Threading;
#endregion
namespace Salient.Excerpts
{
/// <summary>
/// A general purpose Visual Studio 2008 Development Server
/// (WebDev.WebServer.exe) test fixture.
/// Controls a shelled instance of WebDev.WebServer.exe
/// so F5 debugging is not possible with this server.
/// </summary>
/// NOTE: code from various namespaces/classes in the
/// Salient project have been merged into this
/// single class for this post in the interest of brevity.
public class WebDevServer
{
public WebDevServer()
{
}
public string ApplicationPath { get; private set; }
public string HostName { get; private set; }
public int Port { get; private set; }
public string VirtualPath { get; private set; }
public string RootUrl
{
get { return string.Format(CultureInfo.InvariantCulture,
"http://{0}:{1}{2}", HostName, Port, VirtualPath); }
}
private int ProcessId { get; set; }
private static string ProgramFilesx86
{
get
{
if (8 == IntPtr.Size ||
(!String.IsNullOrEmpty(Environment.GetEnvironmentVariable
("PROCESSOR_ARCHITEW6432"))))
{
return Environment.GetEnvironmentVariable("ProgramFiles(x86)");
}
return Environment.GetEnvironmentVariable("ProgramFiles");
}
}
/// <summary>
/// Combine the RootUrl of the running web application with the
/// relative URL specified.
/// </summary>
public virtual Uri NormalizeUri(string relativeUrl)
{
return new Uri(RootUrl + relativeUrl);
}
/// <summary>
/// Will start "localhost" on first available port in the range
/// 8000-10000 with vpath "/"
/// </summary>
/// <param name="applicationPath"></param>
public void StartServer(string applicationPath)
{
StartServer(applicationPath, GetAvailablePort
(8000, 10000, IPAddress.Loopback, true), "/", "localhost");
}
/// <summary>
/// </summary>
/// <param name="applicationPath">Physical path to application.</param>
/// <param name="port">Port to listen on.</param>
/// <param name="virtualPath">Optional. defaults to "/"</param>
/// <param name="hostName">Optional.
/// Is used to construct RootUrl. Defaults to "localhost"</param>
public void StartServer(string applicationPath, int port,
string virtualPath, string hostName)
{
applicationPath = Path.GetFullPath(applicationPath);
hostName = string.IsNullOrEmpty(hostName) ? "localhost" : hostName;
virtualPath = String.Format("/{0}/",
(virtualPath ?? string.Empty).Trim('/')).Replace("//", "/");
if (GetRunningInstance(applicationPath, port, virtualPath) != null)
{
return;
}
IPAddress ipAddress = IPAddress.Loopback;
if (!IsPortAvailable(ipAddress, port))
{
throw new Exception(string.Format("Port {0} is in use.", port));
}
string arguments = String.Format
(CultureInfo.InvariantCulture, "/port:{0} /path:\"{1}\" /vpath:\"{2}\"",
port, applicationPath, virtualPath);
using (Process proc = new Process())
{
proc.StartInfo = new ProcessStartInfo
{
FileName = GetWebDevExecutablePath(),
Arguments = arguments,
CreateNoWindow = true
};
bool started = proc.Start();
if (!started)
{
throw new Exception("Error starting server");
}
ProcessId = proc.Id;
}
ApplicationPath = applicationPath;
Port = port;
VirtualPath = virtualPath;
HostName = hostName;
}
/// <summary>
/// After stopping an instance of WebDevWebServer.exe,
/// the orphaned tray notification icon will remain.
/// This can be annoying but simply running your mouse over them
/// will dispose of it.
/// I think there may be some Win32 voodoo to deal with this
/// but is a very low priority right now.
/// </summary>
public void StopServer()
{
try
{
WebDevServer instance = GetRunningInstance
(ApplicationPath, Port, VirtualPath);
if (instance != null)
{
using (Process process = Process.GetProcessById(instance.ProcessId))
{
if (process.MainWindowHandle != IntPtr.Zero)
{
process.CloseMainWindow();
}
process.Kill();
// allow the port time to be released
process.WaitForExit(100);
}
}
}
// ReSharper disable EmptyGeneralCatchClause
catch
// ReSharper restore EmptyGeneralCatchClause
{
// what can we do about spurious exceptions on Process.Kill?
}
}
public void Dispose()
{
StopServer();
}
#region Instance Management
private const string WebDevPath =
@"Common Files\Microsoft Shared\DevServer\9.0\WebDev.WebServer.exe";
private static readonly Regex RxPath =
new Regex(@"/path:""(?<path>[^""]*)", RegexOptions.ExplicitCapture);
private static readonly Regex RxPort =
new Regex(@"/port:(?<port>\d*)", RegexOptions.ExplicitCapture);
private static readonly Regex RxVPath =
new Regex(@"/vpath:""(?<vpath>[^""]*)", RegexOptions.ExplicitCapture);
/// <summary>
/// Private ctor to build from WMI query
/// </summary>
private WebDevServer(string commandLine, int processId)
{
ProcessId = processId;
Port = Int32.Parse(RxPort.Match(commandLine).Groups["port"].Value);
ApplicationPath = RxPath.Match(commandLine).Groups["path"].Value;
VirtualPath =
String.Format("/{0}/", RxVPath.Match(commandLine).Groups
["vpath"].Value.Trim('/')).Replace("//", "/");
HostName = "localhost";
}
public static WebDevServer GetRunningInstance(string applicationPath,
int port, string virtualPath)
{
return GetRunningInstances().FirstOrDefault(s =>
string.Compare(s.ApplicationPath, applicationPath,
StringComparison.OrdinalIgnoreCase) == 0 &&
string.Compare(s.VirtualPath, virtualPath,
StringComparison.OrdinalIgnoreCase) == 0 &&
s.Port == port);
}
/// <summary>
/// Queries WMI and builds a list of WebDevWebServer
/// representing all running instances.
/// </summary>
public static List<WebDevServer> GetRunningInstances()
{
List<WebDevServer> returnValue = new List<WebDevServer>();
const string query = "select CommandLine,ProcessId from
Win32_Process where Name='WebDev.WebServer.EXE'";
using (ManagementObjectSearcher searcher =
new ManagementObjectSearcher(query))
{
using (ManagementObjectCollection results = searcher.Get())
{
foreach (ManagementObject process in results)
{
returnValue.Add(
new WebDevServer(process["CommandLine"].ToString(),
int.Parse(process["ProcessId"].ToString())));
process.Dispose();
}
}
}
return returnValue;
}
public static string GetWebDevExecutablePath()
{
string exePath = Path.Combine(ProgramFilesx86, WebDevPath);
if (!File.Exists(exePath))
{
throw new FileNotFoundException(exePath);
}
return exePath;
}
/// <summary>
/// Gently polls specified IP:Port to determine if it is available.
/// </summary>
/// <param name="ipAddress"></param>
/// <param name="port"></param>
public static bool IsPortAvailable(IPAddress ipAddress, int port)
{
bool portAvailable = false;
for (int i = 0; i < 5; i++)
{
portAvailable = GetAvailablePort(port, port, ipAddress, true) == port;
if (portAvailable)
{
break;
}
// be a little patient and wait for the port if necessary,
// the previous occupant may have just vacated
Thread.Sleep(100);
}
return portAvailable;
}
/// <summary>
/// Returns first available port on the specified IP address.
/// The port scan excludes ports that are open on ANY loopback adapter.
///
/// If the address upon which a port is requested is an 'ANY' address all
/// ports that are open on ANY IP are excluded.
/// </summary>
/// <param name="rangeStart"></param>
/// <param name="rangeEnd"></param>
/// <param name="ip">The IP address upon which to search for available port.
/// </param>
/// <param name="includeIdlePorts">If true includes ports in
/// TIME_WAIT state in results.
/// TIME_WAIT state is typically cool down period for recently released ports.
/// </param>
/// <returns></returns>
public static int GetAvailablePort(int rangeStart,
int rangeEnd, IPAddress ip, bool includeIdlePorts)
{
IPGlobalProperties ipProps = IPGlobalProperties.GetIPGlobalProperties();
// if the ip we want a port on is an 'any' or loopback port
// we need to exclude all ports that are active on any IP
Func<IPAddress, bool> isIpAnyOrLoopBack = i => IPAddress.Any.Equals(i) ||
IPAddress.IPv6Any.Equals(i) ||
IPAddress.Loopback.Equals(i) ||
IPAddress.IPv6Loopback.
Equals(i);
// get all active ports on specified IP.
List<ushort> excludedPorts = new List<ushort>();
// if a port is open on an 'any' or 'loopback' interface
// then include it in the excludedPorts
excludedPorts.AddRange(from n in ipProps.GetActiveTcpConnections()
where
n.LocalEndPoint.Port >= rangeStart &&
n.LocalEndPoint.Port <= rangeEnd && (
isIpAnyOrLoopBack(ip) ||
n.LocalEndPoint.Address.Equals(ip) ||
isIpAnyOrLoopBack(n.LocalEndPoint.Address)) &&
(!includeIdlePorts || n.State
!= TcpState.TimeWait)
select (ushort)n.LocalEndPoint.Port);
excludedPorts.AddRange(from n in ipProps.GetActiveTcpListeners()
where n.Port >= rangeStart && n.Port <= rangeEnd && (
isIpAnyOrLoopBack(ip) ||
n.Address.Equals(ip) || isIpAnyOrLoopBack(n.Address))
select (ushort)n.Port);
excludedPorts.AddRange(from n in ipProps.GetActiveUdpListeners()
where n.Port >= rangeStart && n.Port <= rangeEnd && (
isIpAnyOrLoopBack(ip) ||
n.Address.Equals(ip) || isIpAnyOrLoopBack(n.Address))
select (ushort)n.Port);
excludedPorts.Sort();
for (int port = rangeStart; port <= rangeEnd; port++)
{
if (!excludedPorts.Contains((ushort)port))
{
return port;
}
}
return 0;
}
#endregion
}
}
重新设计
虽然在某些情况下,使用进程外的可执行文件可能是最佳策略,但理想情况下,在这种情况下,在调用代码的进程中使用程序集引用类型,测试将更好地控制服务器,并消除 WebDevServer 中发现的大部分 WMI 和进程管理代码。
回顾 *WebDev.WebServer.exe*,很明显它只是 Microsoft.VisualStudio.WebHost.Server
的包装器。 WebHost.Server
公开了我们需要的所有方法,可以有效地且干净地控制 Web 服务器的实例。
作为额外的奖励,由于 Web 服务器是直接在代码中实例化的,因此我们能够 F5 调试发出 HTTP 请求的代码,我们的测试以及正在测试的站点,就像我们能够直接从 Visual Studio 使用开发服务器一样。
这里细微的差别是我们可以完全控制服务器实例,使其更适合无头场景(例如在持续集成中),并且消除了在交互式开发和签入/测试之间调整配置的必要性。
最重要的是:除非您有令人信服的理由使用进程外的 WebDevServer
,否则您在使用进程内的 WebHostServer
时将获得更好的体验。
注意:Microsoft.VisualStudio.WebHost
命名空间包含在文件 *WebDev.WebHost.dll* 中。 此文件位于 GAC 中,但无法从 Visual Studio 中添加对此程序集的引用。
要添加引用,您需要在文本编辑器中打开 *.csproj* 文件并手动添加引用。
查找包含项目引用的 ItemGroup
,并添加以下元素
对于使用 .Net Framework 3.5 的 ASP.Net 2.0-3.5
<Reference Include="WebDev.WebHost, Version=9.0.0.0,
Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=x86">
<Private>False</Private>
</Reference>
对于使用 .Net Framework 4.0 的 ASP.Net 2.0-3.5
<Reference Include="WebDev.WebHost20, Version=10.0.0.0,
Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=x86">
<Private>False</Private>
</Reference>
对于使用 .Net Framework 4.0 的 ASP.Net 4.0
<Reference Include="WebDev.WebHost40, Version=10.0.0.0,
Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=x86">
<Private>False</Private>
</Reference>
您可以打开文本编辑器中的 *WebHostServer.csproj* 以获取示例。
WebHostServer
将 WebHostServer 与 NUnit 结合使用
using System.Net;
using NUnit.Framework;
namespace Salient.Excerpts
{
[TestFixture]
public class WebHostServerFixture : WebHostServer
{
[TestFixtureSetUp]
public void TestFixtureSetUp()
{
StartServer(@"..\..\..\..\TestSite");
// is the equivalent of
// StartServer(@"..\..\..\..\TestSite",
// GetAvailablePort(8000, 10000, IPAddress.Loopback, true),
// "/", "localhost");
}
[TestFixtureTearDown]
public void TestFixtureTearDown()
{
StopServer();
}
[Test]
public void Test()
{
// while a reference to the web app under test is not necessary,
// if you do add a reference to this test project you may F5 debug your tests.
// if you debug this test you will break in Default.aspx.cs
string html = new WebClient().DownloadString(NormalizeUri("Default.aspx"));
}
}
}
WebHostServer.cs
// Project: Salient
// http://salient.codeplex.com
// Date: April 16 2010
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Threading;
using Microsoft.VisualStudio.WebHost;
namespace Salient.Excerpts
{
/// <summary>
/// A general purpose Microsoft.VisualStudio.WebHost.Server test fixture.
/// WebHost.Server is the core of the Visual Studio Development Server
/// (WebDev.WebServer).
///
/// This server is run in-process and may be used in F5 debugging.
/// </summary>
/// <remarks>
/// If you are adding this source code to a new project, You will need to
/// manually add a reference to WebDev.WebHost.dll to your project. It cannot
/// be added from within Visual Studio.
///
/// Please see the Readme.txt accompanying this code for details.
/// </remarks>
/// NOTE: code from various namespaces/classes in the Salient
/// project have been merged into this
/// single class for this post in the interest of brevity
public class WebHostServer
{
private Server _server;
public string ApplicationPath { get; private set; }
public string HostName { get; private set; }
public int Port { get; private set; }
public string VirtualPath { get; private set; }
public string RootUrl
{
get { return string.Format(CultureInfo.InvariantCulture,
"http://{0}:{1}{2}", HostName, Port, VirtualPath); }
}
/// <summary>
/// Combine the RootUrl of the running web application
/// with the relative URL specified.
/// </summary>
public virtual Uri NormalizeUri(string relativeUrl)
{
return new Uri(RootUrl + relativeUrl);
}
/// <summary>
/// Will start "localhost" on first available port in the
/// range 8000-10000 with vpath "/"
/// </summary>
/// <param name="applicationPath"></param>
public void StartServer(string applicationPath)
{
StartServer(applicationPath, GetAvailablePort
(8000, 10000, IPAddress.Loopback, true), "/", "localhost");
}
/// <summary>
/// </summary>
/// <param name="applicationPath">Physical path to application.</param>
/// <param name="port">Port to listen on.</param>
/// <param name="virtualPath">Optional. defaults to "/"</param>
/// <param name="hostName">Optional. Is used to construct
/// RootUrl. Defaults to "localhost"</param>
public void StartServer(string applicationPath, int port,
string virtualPath, string hostName)
{
if (_server != null)
{
throw new InvalidOperationException("Server already started");
}
// WebHost.Server will not run on any other IP
IPAddress ipAddress = IPAddress.Loopback;
if(!IsPortAvailable(ipAddress, port))
{
throw new Exception(string.Format("Port {0} is in use.", port));
}
applicationPath = Path.GetFullPath(applicationPath);
virtualPath = String.Format("/{0}/", (virtualPath ??
string.Empty).Trim('/')).Replace("//", "/");
_server = new Server(port, virtualPath, applicationPath, false, false);
_server.Start();
ApplicationPath = applicationPath;
Port = port;
VirtualPath = virtualPath;
HostName = string.IsNullOrEmpty(hostName) ? "localhost" : hostName;
}
/// <summary>
/// Stops the server.
/// </summary>
public void StopServer()
{
if (_server != null)
{
_server.Stop();
_server = null;
// allow some time to release the port
Thread.Sleep(100);
}
}
public void Dispose()
{
StopServer();
}
/// <summary>
/// Gently polls specified IP:Port to determine if it is available.
/// </summary>
/// <param name="ipAddress"></param>
/// <param name="port"></param>
public static bool IsPortAvailable(IPAddress ipAddress, int port)
{
bool portAvailable = false;
for (int i = 0; i < 5; i++)
{
portAvailable = GetAvailablePort(port, port, ipAddress, true) == port;
if (portAvailable)
{
break;
}
// be a little patient and wait for the port if necessary,
// the previous occupant may have just vacated
Thread.Sleep(100);
}
return portAvailable;
}
/// <summary>
/// Returns first available port on the specified IP address.
/// The port scan excludes ports that are open on ANY loopback adapter.
///
/// If the address upon which a port is requested is an 'ANY' address all
/// ports that are open on ANY IP are excluded.
/// </summary>
/// <param name="rangeStart"></param>
/// <param name="rangeEnd"></param>
/// <param name="ip">The IP address upon which to search for available port.
/// </param>
/// <param name="includeIdlePorts">If true includes ports in
/// TIME_WAIT state in results.
/// TIME_WAIT state is typically cool down period for
/// recently released ports.</param>
/// <returns></returns>
public static int GetAvailablePort(int rangeStart,
int rangeEnd, IPAddress ip, bool includeIdlePorts)
{
IPGlobalProperties ipProps = IPGlobalProperties.GetIPGlobalProperties();
// if the IP we want a port on is an 'any' or
// loopback port we need to exclude all ports that are active on any IP
Func<IPAddress, bool> isIpAnyOrLoopBack = i => IPAddress.Any.Equals(i) ||
IPAddress.IPv6Any.Equals(i) ||
IPAddress.Loopback.Equals(i) ||
IPAddress.IPv6Loopback.
Equals(i);
// get all active ports on specified IP.
List<ushort> excludedPorts = new List<ushort>();
// if a port is open on an 'any' or 'loopback'
// interface then include it in the excludedPorts
excludedPorts.AddRange(from n in ipProps.GetActiveTcpConnections()
where
n.LocalEndPoint.Port >= rangeStart &&
n.LocalEndPoint.Port <= rangeEnd && (
isIpAnyOrLoopBack(ip) ||
n.LocalEndPoint.Address.Equals(ip) ||
isIpAnyOrLoopBack(n.LocalEndPoint.Address)) &&
(!includeIdlePorts || n.State
!= TcpState.TimeWait)
select (ushort)n.LocalEndPoint.Port);
excludedPorts.AddRange(from n in ipProps.GetActiveTcpListeners()
where n.Port >= rangeStart && n.Port <= rangeEnd && (
isIpAnyOrLoopBack(ip) || n.Address.Equals(ip)
|| isIpAnyOrLoopBack(n.Address))
select (ushort)n.Port);
excludedPorts.AddRange(from n in ipProps.GetActiveUdpListeners()
where n.Port >= rangeStart && n.Port <= rangeEnd && (
isIpAnyOrLoopBack(ip) || n.Address.Equals(ip)
|| isIpAnyOrLoopBack(n.Address))
select (ushort)n.Port);
excludedPorts.Sort();
for (int port = rangeStart; port <= rangeEnd; port++)
{
if (!excludedPorts.Contains((ushort)port))
{
return port;
}
}
return 0;
}
}
}